feat: initialize AirGate - Volcengine IAM sub-account management platform
Backend (Django 4.2 + DRF): - Admin auth with SimpleJWT - Volcengine API client with HMAC-SHA256 signing - IAM user management (create/sync/import/disable/enable) - Billing query with pagination - Feishu webhook notifications (async) - APScheduler for periodic spending checks - AES-256 encrypted credential storage - API key auth for external system integration Frontend (Vue 3 + Element Plus): - Login page - Dashboard with stats overview - IAM user list with per-user threshold config - Billing view with spending progress bars - Alert history with type filtering - Settings page for global config and Volcengine account management Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
555c86ce76
34
.env.example
Normal file
34
.env.example
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# ===========================================
|
||||||
|
# AirGate 环境变量配置模板
|
||||||
|
# 复制此文件为 .env 并填入真实值
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Django 基础配置
|
||||||
|
DJANGO_SECRET_KEY=change-me-to-a-random-string
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# 数据库(本地开发不需要配置,默认用 SQLite)
|
||||||
|
# USE_MYSQL=false
|
||||||
|
# DB_HOST=
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_NAME=airgate
|
||||||
|
# DB_USER=
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
# 火山引擎主账号密钥(必填,用于管理 IAM 子账号)
|
||||||
|
VOLC_ACCESS_KEY=
|
||||||
|
VOLC_SECRET_KEY=
|
||||||
|
|
||||||
|
# 数据加密密钥(用于加密存储在数据库中的密钥)
|
||||||
|
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
AIRGATE_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# 飞书机器人 Webhook(选填,也可在管理界面中配置)
|
||||||
|
FEISHU_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# AirGate API Key(供外部系统如 AirDrama 调用本系统 API 时使用)
|
||||||
|
AIRGATE_API_KEY=change-me-to-a-random-api-key
|
||||||
|
|
||||||
|
# 消费监控检查间隔(秒,默认 3600 = 1小时)
|
||||||
|
MONITOR_INTERVAL=3600
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
backend/db.sqlite3
|
||||||
|
backend/test_db.sqlite3
|
||||||
|
backend/staticfiles/
|
||||||
|
backend/media/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Research docs (keep in repo for reference)
|
||||||
|
# 火山引擎IAM子账号管控工具_深度研究报告.md
|
||||||
0
backend/apps/__init__.py
Normal file
0
backend/apps/__init__.py
Normal file
0
backend/apps/accounts/__init__.py
Normal file
0
backend/apps/accounts/__init__.py
Normal file
8
backend/apps/accounts/admin.py
Normal file
8
backend/apps/accounts/admin.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from .models import AdminUser
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AdminUser)
|
||||||
|
class AdminUserAdmin(UserAdmin):
|
||||||
|
list_display = ('username', 'is_active', 'is_superuser', 'date_joined')
|
||||||
6
backend/apps/accounts/apps.py
Normal file
6
backend/apps/accounts/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.accounts'
|
||||||
|
verbose_name = '管理员账户'
|
||||||
44
backend/apps/accounts/migrations/0001_initial.py
Normal file
44
backend/apps/accounts/migrations/0001_initial.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 4.2.21 on 2026-03-19 04:58
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AdminUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '管理员',
|
||||||
|
'verbose_name_plural': '管理员',
|
||||||
|
'db_table': 'airgate_admin_user',
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/accounts/migrations/__init__.py
Normal file
0
backend/apps/accounts/migrations/__init__.py
Normal file
14
backend/apps/accounts/models.py
Normal file
14
backend/apps/accounts/models.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUser(AbstractUser):
|
||||||
|
"""AirGate 管理员用户"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '管理员'
|
||||||
|
verbose_name_plural = '管理员'
|
||||||
|
db_table = 'airgate_admin_user'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.username
|
||||||
12
backend/apps/accounts/serializers.py
Normal file
12
backend/apps/accounts/serializers.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
username = serializers.CharField()
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoSerializer(serializers.Serializer):
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
username = serializers.CharField()
|
||||||
|
is_superuser = serializers.BooleanField()
|
||||||
8
backend/apps/accounts/urls.py
Normal file
8
backend/apps/accounts/urls.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('login/', views.login_view),
|
||||||
|
path('refresh/', views.refresh_view),
|
||||||
|
path('me/', views.me_view),
|
||||||
|
]
|
||||||
60
backend/apps/accounts/views.py
Normal file
60
backend/apps/accounts/views.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from .serializers import LoginSerializer, UserInfoSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def login_view(request):
|
||||||
|
serializer = LoginSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
user = authenticate(
|
||||||
|
username=serializer.validated_data['username'],
|
||||||
|
password=serializer.validated_data['password'],
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
return Response(
|
||||||
|
{'error': 'invalid_credentials', 'message': '用户名或密码错误'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
return Response(
|
||||||
|
{'error': 'user_disabled', 'message': '账号已停用'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return Response({
|
||||||
|
'access': str(refresh.access_token),
|
||||||
|
'refresh': str(refresh),
|
||||||
|
'user': UserInfoSerializer(user).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def refresh_view(request):
|
||||||
|
token = request.data.get('refresh')
|
||||||
|
if not token:
|
||||||
|
return Response(
|
||||||
|
{'error': 'missing_token', 'message': '缺少 refresh token'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
refresh = RefreshToken(token)
|
||||||
|
return Response({'access': str(refresh.access_token)})
|
||||||
|
except Exception:
|
||||||
|
return Response(
|
||||||
|
{'error': 'invalid_token', 'message': 'token 无效或已过期'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def me_view(request):
|
||||||
|
return Response(UserInfoSerializer(request.user).data)
|
||||||
0
backend/apps/monitor/__init__.py
Normal file
0
backend/apps/monitor/__init__.py
Normal file
30
backend/apps/monitor/admin.py
Normal file
30
backend/apps/monitor/admin.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(VolcAccount)
|
||||||
|
class VolcAccountAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'access_key_hint', 'is_active', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(IAMUser)
|
||||||
|
class IAMUserAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
||||||
|
'current_month_spending', 'alert_threshold', 'disable_threshold')
|
||||||
|
list_filter = ('status', 'monitor_enabled')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(GlobalConfig)
|
||||||
|
class GlobalConfigAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('default_alert_threshold', 'default_disable_threshold', 'monitor_interval_seconds')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AlertRecord)
|
||||||
|
class AlertRecordAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'alert_type', 'spending_amount', 'notified', 'created_at')
|
||||||
|
list_filter = ('alert_type', 'notified')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SpendingRecord)
|
||||||
|
class SpendingRecordAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('iam_user', 'bill_period', 'amount', 'updated_at')
|
||||||
14
backend/apps/monitor/apps.py
Normal file
14
backend/apps/monitor/apps.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.monitor'
|
||||||
|
verbose_name = 'IAM 监控管理'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from utils.scheduler import start_scheduler
|
||||||
|
import os
|
||||||
|
# Only start scheduler in the main process (not in migrate/shell/etc)
|
||||||
|
if os.environ.get('RUN_MAIN') == 'true' or os.environ.get('GUNICORN_RUNNING'):
|
||||||
|
start_scheduler()
|
||||||
117
backend/apps/monitor/migrations/0001_initial.py
Normal file
117
backend/apps/monitor/migrations/0001_initial.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Generated by Django 4.2.21 on 2026-03-19 04:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GlobalConfig',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('default_alert_threshold', models.DecimalField(decimal_places=2, default=1000, max_digits=12, verbose_name='默认告警阈值(元)')),
|
||||||
|
('default_disable_threshold', models.DecimalField(decimal_places=2, default=5000, max_digits=12, verbose_name='默认停用阈值(元)')),
|
||||||
|
('monitor_interval_seconds', models.IntegerField(default=3600, verbose_name='监控间隔(秒)')),
|
||||||
|
('feishu_webhook_url', models.URLField(blank=True, max_length=500, verbose_name='飞书 Webhook URL')),
|
||||||
|
('feishu_alert_mobiles', models.CharField(blank=True, max_length=500, verbose_name='飞书通知手机号(逗号分隔)')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '全局配置',
|
||||||
|
'verbose_name_plural': '全局配置',
|
||||||
|
'db_table': 'airgate_global_config',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VolcAccount',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='默认主账号', max_length=100, verbose_name='账号名称')),
|
||||||
|
('access_key_enc', models.TextField(blank=True, verbose_name='AccessKey(加密)')),
|
||||||
|
('secret_key_enc', models.TextField(blank=True, verbose_name='SecretKey(加密)')),
|
||||||
|
('access_key_hint', models.CharField(blank=True, max_length=20, verbose_name='AK 提示(前4后4)')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='启用')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '火山主账号',
|
||||||
|
'verbose_name_plural': '火山主账号',
|
||||||
|
'db_table': 'airgate_volc_account',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IAMUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('username', models.CharField(db_index=True, max_length=200, verbose_name='IAM 用户名')),
|
||||||
|
('display_name', models.CharField(blank=True, max_length=200, verbose_name='显示名')),
|
||||||
|
('user_id', models.CharField(blank=True, max_length=100, verbose_name='火山 UserID')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='邮箱')),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20, verbose_name='手机号')),
|
||||||
|
('project_name', models.CharField(blank=True, help_text='用于按项目维度追踪消费', max_length=200, verbose_name='关联项目名')),
|
||||||
|
('status', models.CharField(choices=[('active', '正常'), ('disabled', '已停用'), ('unknown', '未知')], default='unknown', max_length=20, verbose_name='状态')),
|
||||||
|
('access_key_ids', models.JSONField(blank=True, default=list, verbose_name='AccessKey ID 列表')),
|
||||||
|
('monitor_enabled', models.BooleanField(default=True, verbose_name='启用消费监控')),
|
||||||
|
('auto_disable_enabled', models.BooleanField(default=True, verbose_name='启用自动停用')),
|
||||||
|
('alert_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='为空则使用全局默认值', max_digits=12, null=True, verbose_name='告警阈值(元)')),
|
||||||
|
('disable_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='为空则使用全局默认值', max_digits=12, null=True, verbose_name='停用阈值(元)')),
|
||||||
|
('current_month_spending', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='本月消费(元)')),
|
||||||
|
('spending_updated_at', models.DateTimeField(blank=True, null=True, verbose_name='消费更新时间')),
|
||||||
|
('remark', models.TextField(blank=True, verbose_name='备注')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('volc_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iam_users', to='monitor.volcaccount')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'IAM 子账号',
|
||||||
|
'verbose_name_plural': 'IAM 子账号',
|
||||||
|
'db_table': 'airgate_iam_user',
|
||||||
|
'unique_together': {('volc_account', 'username')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AlertRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('alert_type', models.CharField(choices=[('warning', '告警'), ('disable', '自动停用'), ('error', '错误'), ('manual', '手动操作')], max_length=20, verbose_name='告警类型')),
|
||||||
|
('title', models.CharField(max_length=200, verbose_name='标题')),
|
||||||
|
('content', models.TextField(verbose_name='详情')),
|
||||||
|
('spending_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='触发时消费金额')),
|
||||||
|
('threshold_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='触发阈值')),
|
||||||
|
('notified', models.BooleanField(default=False, verbose_name='已通知')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('iam_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='monitor.iamuser')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '告警记录',
|
||||||
|
'verbose_name_plural': '告警记录',
|
||||||
|
'db_table': 'airgate_alert_record',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SpendingRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bill_period', models.CharField(db_index=True, max_length=7, verbose_name='账期 (YYYY-MM)')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='消费金额(元)')),
|
||||||
|
('detail', models.JSONField(blank=True, default=dict, verbose_name='消费明细')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='spending_records', to='monitor.iamuser')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '消费记录',
|
||||||
|
'verbose_name_plural': '消费记录',
|
||||||
|
'db_table': 'airgate_spending_record',
|
||||||
|
'unique_together': {('iam_user', 'bill_period')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/monitor/migrations/__init__.py
Normal file
0
backend/apps/monitor/migrations/__init__.py
Normal file
148
backend/apps/monitor/models.py
Normal file
148
backend/apps/monitor/models.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class VolcAccount(models.Model):
|
||||||
|
"""火山引擎主账号配置(加密存储)"""
|
||||||
|
name = models.CharField('账号名称', max_length=100, default='默认主账号')
|
||||||
|
access_key_enc = models.TextField('AccessKey(加密)', blank=True)
|
||||||
|
secret_key_enc = models.TextField('SecretKey(加密)', blank=True)
|
||||||
|
access_key_hint = models.CharField('AK 提示(前4后4)', max_length=20, blank=True)
|
||||||
|
is_active = models.BooleanField('启用', default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '火山主账号'
|
||||||
|
verbose_name_plural = '火山主账号'
|
||||||
|
db_table = 'airgate_volc_account'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.access_key_hint})"
|
||||||
|
|
||||||
|
|
||||||
|
class IAMUser(models.Model):
|
||||||
|
"""受管理的 IAM 子账号"""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
ACTIVE = 'active', '正常'
|
||||||
|
DISABLED = 'disabled', '已停用'
|
||||||
|
UNKNOWN = 'unknown', '未知'
|
||||||
|
|
||||||
|
volc_account = models.ForeignKey(VolcAccount, on_delete=models.CASCADE, related_name='iam_users')
|
||||||
|
username = models.CharField('IAM 用户名', max_length=200, db_index=True)
|
||||||
|
display_name = models.CharField('显示名', max_length=200, blank=True)
|
||||||
|
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
|
||||||
|
email = models.EmailField('邮箱', blank=True)
|
||||||
|
phone = models.CharField('手机号', max_length=20, blank=True)
|
||||||
|
project_name = models.CharField('关联项目名', max_length=200, blank=True,
|
||||||
|
help_text='用于按项目维度追踪消费')
|
||||||
|
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
|
||||||
|
|
||||||
|
# Access keys (stored as JSON list of AK IDs, not secrets)
|
||||||
|
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
||||||
|
|
||||||
|
# Monitoring config
|
||||||
|
monitor_enabled = models.BooleanField('启用消费监控', default=True)
|
||||||
|
auto_disable_enabled = models.BooleanField('启用自动停用', default=True)
|
||||||
|
alert_threshold = models.DecimalField('告警阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True,
|
||||||
|
help_text='为空则使用全局默认值')
|
||||||
|
disable_threshold = models.DecimalField('停用阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True,
|
||||||
|
help_text='为空则使用全局默认值')
|
||||||
|
|
||||||
|
# Spending cache
|
||||||
|
current_month_spending = models.DecimalField('本月消费(元)', max_digits=12, decimal_places=2, default=0)
|
||||||
|
spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
|
||||||
|
|
||||||
|
remark = models.TextField('备注', blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'IAM 子账号'
|
||||||
|
verbose_name_plural = 'IAM 子账号'
|
||||||
|
db_table = 'airgate_iam_user'
|
||||||
|
unique_together = [('volc_account', 'username')]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.display_name or self.username} ({self.status})"
|
||||||
|
|
||||||
|
def get_alert_threshold(self):
|
||||||
|
if self.alert_threshold is not None:
|
||||||
|
return self.alert_threshold
|
||||||
|
config = GlobalConfig.get_solo()
|
||||||
|
return config.default_alert_threshold
|
||||||
|
|
||||||
|
def get_disable_threshold(self):
|
||||||
|
if self.disable_threshold is not None:
|
||||||
|
return self.disable_threshold
|
||||||
|
config = GlobalConfig.get_solo()
|
||||||
|
return config.default_disable_threshold
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalConfig(models.Model):
|
||||||
|
"""全局配置(单例)"""
|
||||||
|
default_alert_threshold = models.DecimalField('默认告警阈值(元)', max_digits=12, decimal_places=2, default=1000)
|
||||||
|
default_disable_threshold = models.DecimalField('默认停用阈值(元)', max_digits=12, decimal_places=2, default=5000)
|
||||||
|
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600)
|
||||||
|
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
|
||||||
|
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '全局配置'
|
||||||
|
verbose_name_plural = '全局配置'
|
||||||
|
db_table = 'airgate_global_config'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_solo(cls):
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '全局配置'
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRecord(models.Model):
|
||||||
|
"""告警记录"""
|
||||||
|
|
||||||
|
class AlertType(models.TextChoices):
|
||||||
|
WARNING = 'warning', '告警'
|
||||||
|
DISABLE = 'disable', '自动停用'
|
||||||
|
ERROR = 'error', '错误'
|
||||||
|
MANUAL = 'manual', '手动操作'
|
||||||
|
|
||||||
|
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='alerts', null=True, blank=True)
|
||||||
|
alert_type = models.CharField('告警类型', max_length=20, choices=AlertType.choices)
|
||||||
|
title = models.CharField('标题', max_length=200)
|
||||||
|
content = models.TextField('详情')
|
||||||
|
spending_amount = models.DecimalField('触发时消费金额', max_digits=12, decimal_places=2, null=True, blank=True)
|
||||||
|
threshold_amount = models.DecimalField('触发阈值', max_digits=12, decimal_places=2, null=True, blank=True)
|
||||||
|
notified = models.BooleanField('已通知', default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '告警记录'
|
||||||
|
verbose_name_plural = '告警记录'
|
||||||
|
db_table = 'airgate_alert_record'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[{self.alert_type}] {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class SpendingRecord(models.Model):
|
||||||
|
"""月度消费快照"""
|
||||||
|
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
|
||||||
|
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
|
||||||
|
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
|
||||||
|
detail = models.JSONField('消费明细', default=dict, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '消费记录'
|
||||||
|
verbose_name_plural = '消费记录'
|
||||||
|
db_table = 'airgate_spending_record'
|
||||||
|
unique_together = [('iam_user', 'bill_period')]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
|
||||||
11
backend/apps/monitor/permissions.py
Normal file
11
backend/apps/monitor/permissions.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class IsAPIKeyAuth(BasePermission):
|
||||||
|
"""允许通过 X-API-Key 头认证(供外部系统如 AirDrama 调用)"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
api_key = request.headers.get('X-API-Key', '')
|
||||||
|
expected = settings.AIRGATE_API_KEY
|
||||||
|
return bool(expected and api_key == expected)
|
||||||
107
backend/apps/monitor/serializers.py
Normal file
107
backend/apps/monitor/serializers.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord
|
||||||
|
|
||||||
|
|
||||||
|
class VolcAccountSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = VolcAccount
|
||||||
|
fields = ['id', 'name', 'access_key_hint', 'is_active', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['access_key_hint', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class VolcAccountCreateSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(max_length=100, default='默认主账号')
|
||||||
|
access_key = serializers.CharField(write_only=True)
|
||||||
|
secret_key = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IAMUserSerializer(serializers.ModelSerializer):
|
||||||
|
effective_alert_threshold = serializers.SerializerMethodField()
|
||||||
|
effective_disable_threshold = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IAMUser
|
||||||
|
fields = [
|
||||||
|
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
||||||
|
'project_name', 'status', 'access_key_ids',
|
||||||
|
'monitor_enabled', 'auto_disable_enabled',
|
||||||
|
'alert_threshold', 'disable_threshold',
|
||||||
|
'effective_alert_threshold', 'effective_disable_threshold',
|
||||||
|
'current_month_spending', 'spending_updated_at',
|
||||||
|
'remark', 'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||||
|
'current_month_spending', 'spending_updated_at',
|
||||||
|
'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_effective_alert_threshold(self, obj):
|
||||||
|
return str(obj.get_alert_threshold())
|
||||||
|
|
||||||
|
def get_effective_disable_threshold(self, obj):
|
||||||
|
return str(obj.get_disable_threshold())
|
||||||
|
|
||||||
|
|
||||||
|
class IAMUserCreateSerializer(serializers.Serializer):
|
||||||
|
username = serializers.CharField(max_length=200)
|
||||||
|
display_name = serializers.CharField(max_length=200, required=False, default='')
|
||||||
|
email = serializers.EmailField(required=False, default='')
|
||||||
|
phone = serializers.CharField(max_length=20, required=False, default='')
|
||||||
|
password = serializers.CharField(write_only=True, required=False, default='')
|
||||||
|
project_name = serializers.CharField(max_length=200, required=False, default='')
|
||||||
|
alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
||||||
|
required=False, allow_null=True)
|
||||||
|
disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
||||||
|
required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IAMUserImportSerializer(serializers.Serializer):
|
||||||
|
username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名')
|
||||||
|
|
||||||
|
|
||||||
|
class IAMUserThresholdSerializer(serializers.Serializer):
|
||||||
|
alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
||||||
|
required=False, allow_null=True)
|
||||||
|
disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
||||||
|
required=False, allow_null=True)
|
||||||
|
monitor_enabled = serializers.BooleanField(required=False)
|
||||||
|
auto_disable_enabled = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalConfigSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = GlobalConfig
|
||||||
|
fields = [
|
||||||
|
'default_alert_threshold', 'default_disable_threshold',
|
||||||
|
'monitor_interval_seconds',
|
||||||
|
'feishu_webhook_url', 'feishu_alert_mobiles',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRecordSerializer(serializers.ModelSerializer):
|
||||||
|
iam_username = serializers.CharField(source='iam_user.username', default='')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AlertRecord
|
||||||
|
fields = [
|
||||||
|
'id', 'iam_user', 'iam_username', 'alert_type', 'title', 'content',
|
||||||
|
'spending_amount', 'threshold_amount', 'notified', 'created_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SpendingRecordSerializer(serializers.ModelSerializer):
|
||||||
|
iam_username = serializers.CharField(source='iam_user.username')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SpendingRecord
|
||||||
|
fields = ['id', 'iam_user', 'iam_username', 'bill_period', 'amount', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSerializer(serializers.Serializer):
|
||||||
|
total_users = serializers.IntegerField()
|
||||||
|
active_users = serializers.IntegerField()
|
||||||
|
disabled_users = serializers.IntegerField()
|
||||||
|
monitored_users = serializers.IntegerField()
|
||||||
|
total_spending = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
recent_alerts = AlertRecordSerializer(many=True)
|
||||||
33
backend/apps/monitor/urls.py
Normal file
33
backend/apps/monitor/urls.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Dashboard
|
||||||
|
path('dashboard/', views.dashboard_view),
|
||||||
|
|
||||||
|
# Volcengine account management
|
||||||
|
path('volc-accounts/', views.volc_account_view),
|
||||||
|
path('volc-accounts/<int:pk>/', views.volc_account_detail_view),
|
||||||
|
path('volc-accounts/<int:pk>/test/', views.volc_account_test_view),
|
||||||
|
|
||||||
|
# IAM user management
|
||||||
|
path('iam-users/', views.iam_user_list_view),
|
||||||
|
path('iam-users/sync/', views.iam_user_sync_view),
|
||||||
|
path('iam-users/import/', views.iam_user_import_view),
|
||||||
|
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
||||||
|
path('iam-users/<int:pk>/update/', views.iam_user_update_view),
|
||||||
|
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
||||||
|
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
||||||
|
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
||||||
|
|
||||||
|
# Billing
|
||||||
|
path('billing/overview/', views.spending_overview_view),
|
||||||
|
path('billing/refresh/', views.spending_refresh_view),
|
||||||
|
path('billing/balance/', views.balance_view),
|
||||||
|
|
||||||
|
# Global config
|
||||||
|
path('config/', views.global_config_view),
|
||||||
|
|
||||||
|
# Alerts
|
||||||
|
path('alerts/', views.alert_list_view),
|
||||||
|
]
|
||||||
419
backend/apps/monitor/views.py
Normal file
419
backend/apps/monitor/views.py
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
"""AirGate 核心 API 视图"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Sum
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from utils.crypto import encrypt, decrypt, make_hint
|
||||||
|
from utils.iam_service import IAMService
|
||||||
|
from utils.billing_service import BillingService
|
||||||
|
from utils.volcengine_client import VolcengineAPIError
|
||||||
|
|
||||||
|
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
||||||
|
from .serializers import (
|
||||||
|
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||||
|
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||||
|
IAMUserThresholdSerializer,
|
||||||
|
GlobalConfigSerializer,
|
||||||
|
AlertRecordSerializer,
|
||||||
|
DashboardSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_volc_account(volc_id=None):
|
||||||
|
"""获取主账号,解密密钥"""
|
||||||
|
if volc_id:
|
||||||
|
account = VolcAccount.objects.get(pk=volc_id)
|
||||||
|
else:
|
||||||
|
account = VolcAccount.objects.filter(is_active=True).first()
|
||||||
|
if not account:
|
||||||
|
return None, '', ''
|
||||||
|
ak = decrypt(account.access_key_enc)
|
||||||
|
sk = decrypt(account.secret_key_enc)
|
||||||
|
return account, ak, sk
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Dashboard ====================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def dashboard_view(request):
|
||||||
|
total = IAMUser.objects.count()
|
||||||
|
active = IAMUser.objects.filter(status=IAMUser.Status.ACTIVE).count()
|
||||||
|
disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count()
|
||||||
|
monitored = IAMUser.objects.filter(monitor_enabled=True).count()
|
||||||
|
total_spending = IAMUser.objects.aggregate(
|
||||||
|
total=Sum('current_month_spending'))['total'] or Decimal('0')
|
||||||
|
recent_alerts = AlertRecord.objects.all()[:10]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'total_users': total,
|
||||||
|
'active_users': active,
|
||||||
|
'disabled_users': disabled,
|
||||||
|
'monitored_users': monitored,
|
||||||
|
'total_spending': total_spending,
|
||||||
|
'recent_alerts': AlertRecordSerializer(recent_alerts, many=True).data,
|
||||||
|
}
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Volcengine Account ====================
|
||||||
|
|
||||||
|
@api_view(['GET', 'POST'])
|
||||||
|
def volc_account_view(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
accounts = VolcAccount.objects.all()
|
||||||
|
return Response(VolcAccountSerializer(accounts, many=True).data)
|
||||||
|
|
||||||
|
serializer = VolcAccountCreateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
d = serializer.validated_data
|
||||||
|
|
||||||
|
account = VolcAccount.objects.create(
|
||||||
|
name=d['name'],
|
||||||
|
access_key_enc=encrypt(d['access_key']),
|
||||||
|
secret_key_enc=encrypt(d['secret_key']),
|
||||||
|
access_key_hint=make_hint(d['access_key']),
|
||||||
|
)
|
||||||
|
return Response(VolcAccountSerializer(account).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['PUT', 'DELETE'])
|
||||||
|
def volc_account_detail_view(request, pk):
|
||||||
|
try:
|
||||||
|
account = VolcAccount.objects.get(pk=pk)
|
||||||
|
except VolcAccount.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found', 'message': '主账号不存在'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
account.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
# PUT: update
|
||||||
|
name = request.data.get('name')
|
||||||
|
if name:
|
||||||
|
account.name = name
|
||||||
|
ak = request.data.get('access_key')
|
||||||
|
sk = request.data.get('secret_key')
|
||||||
|
if ak:
|
||||||
|
account.access_key_enc = encrypt(ak)
|
||||||
|
account.access_key_hint = make_hint(ak)
|
||||||
|
if sk:
|
||||||
|
account.secret_key_enc = encrypt(sk)
|
||||||
|
account.save()
|
||||||
|
return Response(VolcAccountSerializer(account).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def volc_account_test_view(request, pk):
|
||||||
|
"""测试主账号密钥是否有效"""
|
||||||
|
try:
|
||||||
|
account = VolcAccount.objects.get(pk=pk)
|
||||||
|
except VolcAccount.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
ak = decrypt(account.access_key_enc)
|
||||||
|
sk = decrypt(account.secret_key_enc)
|
||||||
|
try:
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
svc.list_users(limit=1)
|
||||||
|
return Response({'status': 'ok', 'message': '密钥验证成功'})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'status': 'error', 'message': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== IAM Users ====================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def iam_user_list_view(request):
|
||||||
|
users = IAMUser.objects.select_related('volc_account').all()
|
||||||
|
status_filter = request.query_params.get('status')
|
||||||
|
if status_filter:
|
||||||
|
users = users.filter(status=status_filter)
|
||||||
|
return Response(IAMUserSerializer(users, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_sync_view(request):
|
||||||
|
"""从火山引擎同步所有已有 IAM 用户"""
|
||||||
|
account, ak, sk = _get_volc_account()
|
||||||
|
if not account:
|
||||||
|
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
imported = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
resp = svc.list_users(limit=100, offset=offset)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
users = resp.get("Result", {}).get("UserMetadata", [])
|
||||||
|
if not users:
|
||||||
|
break
|
||||||
|
|
||||||
|
for u in users:
|
||||||
|
username = u.get("UserName", "")
|
||||||
|
obj, created = IAMUser.objects.update_or_create(
|
||||||
|
volc_account=account,
|
||||||
|
username=username,
|
||||||
|
defaults={
|
||||||
|
'display_name': u.get("DisplayName", ""),
|
||||||
|
'user_id': u.get("UserId", ""),
|
||||||
|
'email': u.get("Email", ""),
|
||||||
|
'phone': u.get("MobilePhone", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
imported.append(username)
|
||||||
|
|
||||||
|
# Sync access keys
|
||||||
|
try:
|
||||||
|
keys = svc.list_access_keys(username)
|
||||||
|
obj.access_key_ids = [k["AccessKeyId"] for k in keys]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sync login status
|
||||||
|
try:
|
||||||
|
profile = svc.get_login_profile(username)
|
||||||
|
login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True)
|
||||||
|
obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED
|
||||||
|
except Exception:
|
||||||
|
obj.status = IAMUser.Status.UNKNOWN
|
||||||
|
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
offset += 100
|
||||||
|
total = resp.get("Result", {}).get("Total", 0)
|
||||||
|
if offset >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
total_count = IAMUser.objects.filter(volc_account=account).count()
|
||||||
|
return Response({
|
||||||
|
'message': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个',
|
||||||
|
'imported': imported,
|
||||||
|
'total': total_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_import_view(request):
|
||||||
|
"""导入指定的已有 IAM 用户"""
|
||||||
|
serializer = IAMUserImportSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
username = serializer.validated_data['username']
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account()
|
||||||
|
if not account:
|
||||||
|
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
resp = svc.get_user(username)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'user_not_found', 'message': f'火山引擎未找到用户: {e}'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
u = resp.get("Result", {}).get("User", {})
|
||||||
|
obj, created = IAMUser.objects.update_or_create(
|
||||||
|
volc_account=account,
|
||||||
|
username=username,
|
||||||
|
defaults={
|
||||||
|
'display_name': u.get("DisplayName", ""),
|
||||||
|
'user_id': u.get("UserId", ""),
|
||||||
|
'email': u.get("Email", ""),
|
||||||
|
'phone': u.get("MobilePhone", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Response({
|
||||||
|
'message': '导入成功' if created else '用户已存在,已更新信息',
|
||||||
|
'user': IAMUserSerializer(obj).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def iam_user_detail_view(request, pk):
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response(IAMUserSerializer(user).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['PUT'])
|
||||||
|
def iam_user_update_view(request, pk):
|
||||||
|
"""更新子账号的本地配置(阈值、开关等)"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = IAMUserThresholdSerializer(data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
for field, value in serializer.validated_data.items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
user.save()
|
||||||
|
return Response(IAMUserSerializer(user).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_disable_view(request, pk):
|
||||||
|
"""停用子账号"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
svc.disable_user(user.username)
|
||||||
|
user.status = IAMUser.Status.DISABLED
|
||||||
|
user.save(update_fields=['status'])
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"手动停用子账号 {user.username}",
|
||||||
|
content=f"操作人: {request.user.username}",
|
||||||
|
)
|
||||||
|
return Response({'message': f'用户 {user.username} 已停用'})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_enable_view(request, pk):
|
||||||
|
"""恢复子账号"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
svc.enable_user(user.username)
|
||||||
|
user.status = IAMUser.Status.ACTIVE
|
||||||
|
user.save(update_fields=['status'])
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"手动恢复子账号 {user.username}",
|
||||||
|
content=f"操作人: {request.user.username}",
|
||||||
|
)
|
||||||
|
return Response({'message': f'用户 {user.username} 已恢复'})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def iam_user_policies_view(request, pk):
|
||||||
|
"""查看子账号的权限策略"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
resp = svc.list_attached_user_policies(user.username)
|
||||||
|
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||||
|
return Response({'policies': policies})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Billing ====================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def spending_overview_view(request):
|
||||||
|
"""消费总览"""
|
||||||
|
bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m"))
|
||||||
|
users = IAMUser.objects.all().order_by('-current_month_spending')
|
||||||
|
return Response({
|
||||||
|
'period': bill_period,
|
||||||
|
'users': IAMUserSerializer(users, many=True).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def spending_refresh_view(request):
|
||||||
|
"""手动刷新消费数据"""
|
||||||
|
from utils.scheduler import check_spending
|
||||||
|
try:
|
||||||
|
check_spending()
|
||||||
|
return Response({'message': '消费数据刷新完成'})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': 'refresh_failed', 'message': str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def balance_view(request):
|
||||||
|
"""查询主账号余额"""
|
||||||
|
account, ak, sk = _get_volc_account()
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
billing = BillingService(ak, sk)
|
||||||
|
result = billing.get_balance()
|
||||||
|
return Response(result)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Global Config ====================
|
||||||
|
|
||||||
|
@api_view(['GET', 'PUT'])
|
||||||
|
def global_config_view(request):
|
||||||
|
config = GlobalConfig.get_solo()
|
||||||
|
if request.method == 'GET':
|
||||||
|
return Response(GlobalConfigSerializer(config).data)
|
||||||
|
|
||||||
|
serializer = GlobalConfigSerializer(config, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Alerts ====================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def alert_list_view(request):
|
||||||
|
alerts = AlertRecord.objects.select_related('iam_user').all()
|
||||||
|
alert_type = request.query_params.get('type')
|
||||||
|
if alert_type:
|
||||||
|
alerts = alerts.filter(alert_type=alert_type)
|
||||||
|
limit = int(request.query_params.get('limit', 50))
|
||||||
|
return Response(AlertRecordSerializer(alerts[:limit], many=True).data)
|
||||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
147
backend/config/settings.py
Normal file
147
backend/config/settings.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# --- Core ---
|
||||||
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key-change-in-production')
|
||||||
|
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||||
|
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',')
|
||||||
|
|
||||||
|
# --- Apps ---
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
# Third party
|
||||||
|
'rest_framework',
|
||||||
|
'corsheaders',
|
||||||
|
# Local apps
|
||||||
|
'apps.accounts',
|
||||||
|
'apps.monitor',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
|
'NAME': os.environ.get('DB_NAME', 'airgate'),
|
||||||
|
'USER': os.environ.get('DB_USER', 'airgate'),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
|
||||||
|
'HOST': os.environ.get('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '3306'),
|
||||||
|
'OPTIONS': {
|
||||||
|
'charset': 'utf8mb4',
|
||||||
|
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
AUTH_USER_MODEL = 'accounts.AdminUser'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- JWT ---
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
],
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
],
|
||||||
|
'DEFAULT_THROTTLE_CLASSES': [
|
||||||
|
'rest_framework.throttling.AnonRateThrottle',
|
||||||
|
'rest_framework.throttling.UserRateThrottle',
|
||||||
|
],
|
||||||
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
|
'anon': '30/minute',
|
||||||
|
'user': '120/minute',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||||
|
'CORS_ALLOWED_ORIGINS',
|
||||||
|
'http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173'
|
||||||
|
).split(',')
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
# --- i18n ---
|
||||||
|
LANGUAGE_CODE = 'zh-hans'
|
||||||
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# --- Static ---
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# --- Volcengine ---
|
||||||
|
VOLC_ACCESS_KEY = os.environ.get('VOLC_ACCESS_KEY', '')
|
||||||
|
VOLC_SECRET_KEY = os.environ.get('VOLC_SECRET_KEY', '')
|
||||||
|
|
||||||
|
# --- Encryption ---
|
||||||
|
AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '')
|
||||||
|
|
||||||
|
# --- Feishu ---
|
||||||
|
FEISHU_WEBHOOK_URL = os.environ.get('FEISHU_WEBHOOK_URL', '')
|
||||||
|
|
||||||
|
# --- API Key (for external systems like AirDrama) ---
|
||||||
|
AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '')
|
||||||
|
|
||||||
|
# --- Monitor ---
|
||||||
|
MONITOR_INTERVAL = int(os.environ.get('MONITOR_INTERVAL', '3600'))
|
||||||
19
backend/config/urls.py
Normal file
19
backend/config/urls.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def healthz(request):
|
||||||
|
return Response({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('healthz/', healthz),
|
||||||
|
path('api/v1/auth/', include('apps.accounts.urls')),
|
||||||
|
path('api/v1/', include('apps.monitor.urls')),
|
||||||
|
]
|
||||||
12
backend/config/wsgi.py
Normal file
12
backend/config/wsgi.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
env_path = Path(__file__).resolve().parent.parent.parent / '.env'
|
||||||
|
if env_path.exists():
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
application = get_wsgi_application()
|
||||||
28
backend/manage.py
Normal file
28
backend/manage.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
# Load .env from project root (one level up from backend/)
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
env_path = Path(__file__).resolve().parent.parent / '.env'
|
||||||
|
if env_path.exists():
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Django==4.2.21
|
||||||
|
djangorestframework==3.15.2
|
||||||
|
djangorestframework-simplejwt==5.4.0
|
||||||
|
django-cors-headers==4.7.0
|
||||||
|
cryptography==44.0.2
|
||||||
|
requests==2.32.3
|
||||||
|
APScheduler==3.11.0
|
||||||
|
python-dotenv==1.1.0
|
||||||
|
gunicorn==23.0.0
|
||||||
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
59
backend/utils/billing_service.py
Normal file
59
backend/utils/billing_service.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""消费查询服务"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from .volcengine_client import get_billing_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingService:
|
||||||
|
"""封装火山引擎 Billing API"""
|
||||||
|
|
||||||
|
def __init__(self, ak: str, sk: str):
|
||||||
|
self.client = get_billing_client(ak, sk)
|
||||||
|
|
||||||
|
def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal:
|
||||||
|
"""查询指定项目的消费总额(带分页)"""
|
||||||
|
total = Decimal("0")
|
||||||
|
offset = 0
|
||||||
|
page_size = 300
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"BillPeriod": bill_period,
|
||||||
|
"Limit": str(page_size),
|
||||||
|
"Offset": str(offset),
|
||||||
|
"GroupTerm": "0",
|
||||||
|
"GroupPeriod": "0",
|
||||||
|
"NeedRecordNum": "1",
|
||||||
|
}
|
||||||
|
result = self.client.call("ListBillDetail", params)
|
||||||
|
items = result.get("Result", {}).get("List", [])
|
||||||
|
record_num = int(result.get("Result", {}).get("Total", 0))
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if project_name and item.get("Project") != project_name:
|
||||||
|
continue
|
||||||
|
amount = item.get("PayableAmount", "0")
|
||||||
|
total += Decimal(str(amount))
|
||||||
|
|
||||||
|
offset += page_size
|
||||||
|
if offset >= record_num or not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
def get_bill_overview(self, bill_period: str) -> dict:
|
||||||
|
"""获取账单总览(按产品维度)"""
|
||||||
|
result = self.client.call("ListBillOverviewByProd", {
|
||||||
|
"BillPeriod": bill_period,
|
||||||
|
"Limit": "100",
|
||||||
|
"NeedRecordNum": "1",
|
||||||
|
})
|
||||||
|
return result.get("Result", {})
|
||||||
|
|
||||||
|
def get_balance(self) -> dict:
|
||||||
|
"""查询主账号余额"""
|
||||||
|
result = self.client.call("QueryBalanceAcct")
|
||||||
|
return result.get("Result", {})
|
||||||
56
backend/utils/crypto.py
Normal file
56
backend/utils/crypto.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""AES 加密/解密工具,用于安全存储火山引擎密钥"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_fernet = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet():
|
||||||
|
global _fernet
|
||||||
|
if _fernet is not None:
|
||||||
|
return _fernet
|
||||||
|
|
||||||
|
key = settings.AIRGATE_ENCRYPTION_KEY
|
||||||
|
if not key:
|
||||||
|
logger.warning("AIRGATE_ENCRYPTION_KEY 未设置,密钥将以明文存储!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
return _fernet
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"加密密钥格式错误: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
if not plaintext:
|
||||||
|
return ''
|
||||||
|
f = _get_fernet()
|
||||||
|
if f is None:
|
||||||
|
return plaintext
|
||||||
|
return f.encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(ciphertext: str) -> str:
|
||||||
|
if not ciphertext:
|
||||||
|
return ''
|
||||||
|
f = _get_fernet()
|
||||||
|
if f is None:
|
||||||
|
return ciphertext
|
||||||
|
try:
|
||||||
|
return f.decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken:
|
||||||
|
logger.error("解密失败:密文无效或加密密钥已变更")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def make_hint(ak: str) -> str:
|
||||||
|
"""生成 AK 脱敏提示,如 AKLT****3A"""
|
||||||
|
if len(ak) <= 8:
|
||||||
|
return '****'
|
||||||
|
return f"{ak[:4]}****{ak[-4:]}"
|
||||||
42
backend/utils/feishu.py
Normal file
42
backend/utils/feishu.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""飞书机器人通知"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_feishu_alert(webhook_url: str, title: str, content: str,
|
||||||
|
template: str = "red"):
|
||||||
|
"""发送飞书卡片消息(非阻塞)"""
|
||||||
|
if not webhook_url:
|
||||||
|
logger.warning(f"飞书 Webhook 未配置,跳过通知: {title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
payload = {
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": {
|
||||||
|
"config": {"wide_screen_mode": True},
|
||||||
|
"header": {
|
||||||
|
"title": {"tag": "plain_text", "content": title},
|
||||||
|
"template": template,
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "div",
|
||||||
|
"text": {"tag": "lark_md", "content": content},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(webhook_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info(f"飞书通知已发送: {title}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"飞书通知发送失败: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_send, daemon=True)
|
||||||
|
thread.start()
|
||||||
120
backend/utils/iam_service.py
Normal file
120
backend/utils/iam_service.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""IAM 子账号管理服务"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from .volcengine_client import get_iam_client, VolcengineAPIError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IAMService:
|
||||||
|
"""封装火山引擎 IAM API 操作"""
|
||||||
|
|
||||||
|
def __init__(self, ak: str, sk: str):
|
||||||
|
self.client = get_iam_client(ak, sk)
|
||||||
|
|
||||||
|
def list_users(self, limit=100, offset=0) -> dict:
|
||||||
|
return self.client.call("ListUsers", {"Limit": str(limit), "Offset": str(offset)})
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> dict:
|
||||||
|
return self.client.call("GetUser", {"UserName": username})
|
||||||
|
|
||||||
|
def create_user(self, username: str, display_name: str = "", email: str = "",
|
||||||
|
phone: str = "") -> dict:
|
||||||
|
params = {"UserName": username}
|
||||||
|
if display_name:
|
||||||
|
params["DisplayName"] = display_name
|
||||||
|
if email:
|
||||||
|
params["Email"] = email
|
||||||
|
if phone:
|
||||||
|
params["MobilePhone"] = phone
|
||||||
|
return self.client.call("CreateUser", params)
|
||||||
|
|
||||||
|
def create_login_profile(self, username: str, password: str,
|
||||||
|
login_allowed: bool = True, must_reset: bool = True) -> dict:
|
||||||
|
return self.client.call("CreateLoginProfile", {
|
||||||
|
"UserName": username,
|
||||||
|
"Password": password,
|
||||||
|
"LoginAllowed": str(login_allowed).lower(),
|
||||||
|
"PasswordResetRequired": str(must_reset).lower(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def update_login_allowed(self, username: str, allowed: bool) -> dict:
|
||||||
|
return self.client.call("UpdateLoginProfile", {
|
||||||
|
"UserName": username,
|
||||||
|
"LoginAllowed": str(allowed).lower(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_login_profile(self, username: str) -> dict:
|
||||||
|
return self.client.call("GetLoginProfile", {"UserName": username})
|
||||||
|
|
||||||
|
def list_access_keys(self, username: str) -> list:
|
||||||
|
resp = self.client.call("ListAccessKeys", {"UserName": username})
|
||||||
|
return resp.get("Result", {}).get("AccessKeyMetadata", [])
|
||||||
|
|
||||||
|
def update_access_key(self, ak_id: str, status: str, username: str = "") -> dict:
|
||||||
|
params = {"AccessKeyId": ak_id, "Status": status}
|
||||||
|
if username:
|
||||||
|
params["UserName"] = username
|
||||||
|
return self.client.call("UpdateAccessKey", params)
|
||||||
|
|
||||||
|
def create_access_key(self, username: str) -> dict:
|
||||||
|
return self.client.call("CreateAccessKey", {"UserName": username})
|
||||||
|
|
||||||
|
def attach_user_policy(self, username: str, policy_name: str,
|
||||||
|
policy_type: str = "System") -> dict:
|
||||||
|
return self.client.call("AttachUserPolicy", {
|
||||||
|
"UserName": username,
|
||||||
|
"PolicyName": policy_name,
|
||||||
|
"PolicyType": policy_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
def detach_user_policy(self, username: str, policy_name: str,
|
||||||
|
policy_type: str = "System") -> dict:
|
||||||
|
return self.client.call("DetachUserPolicy", {
|
||||||
|
"UserName": username,
|
||||||
|
"PolicyName": policy_name,
|
||||||
|
"PolicyType": policy_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
def list_attached_user_policies(self, username: str) -> dict:
|
||||||
|
return self.client.call("ListAttachedUserPolicies", {"UserName": username})
|
||||||
|
|
||||||
|
def disable_user(self, username: str):
|
||||||
|
"""完全停用用户:停控制台 + 停所有 AccessKey"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_login_allowed(username, False)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
errors.append(f"停用控制台失败: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = self.list_access_keys(username)
|
||||||
|
for key in keys:
|
||||||
|
if key.get("Status") == "active":
|
||||||
|
self.update_access_key(key["AccessKeyId"], "inactive", username)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
errors.append(f"停用密钥失败: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors))
|
||||||
|
|
||||||
|
def enable_user(self, username: str):
|
||||||
|
"""恢复用户:恢复控制台 + 恢复所有 AccessKey"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_login_allowed(username, True)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
errors.append(f"恢复控制台失败: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = self.list_access_keys(username)
|
||||||
|
for key in keys:
|
||||||
|
if key.get("Status") == "inactive":
|
||||||
|
self.update_access_key(key["AccessKeyId"], "active", username)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
errors.append(f"恢复密钥失败: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors))
|
||||||
144
backend/utils/scheduler.py
Normal file
144
backend/utils/scheduler.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""定时消费监控任务"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def check_spending():
|
||||||
|
"""定时检查所有子账号消费"""
|
||||||
|
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord
|
||||||
|
from utils.crypto import decrypt
|
||||||
|
from utils.billing_service import BillingService
|
||||||
|
from utils.iam_service import IAMService
|
||||||
|
from utils.feishu import send_feishu_alert
|
||||||
|
|
||||||
|
bill_period = datetime.now().strftime("%Y-%m")
|
||||||
|
config = GlobalConfig.get_solo()
|
||||||
|
|
||||||
|
for volc_account in VolcAccount.objects.filter(is_active=True):
|
||||||
|
ak = decrypt(volc_account.access_key_enc)
|
||||||
|
sk = decrypt(volc_account.secret_key_enc)
|
||||||
|
if not ak or not sk:
|
||||||
|
logger.warning(f"主账号 {volc_account.name} 密钥为空,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
billing = BillingService(ak, sk)
|
||||||
|
iam_svc = IAMService(ak, sk)
|
||||||
|
|
||||||
|
users = IAMUser.objects.filter(
|
||||||
|
volc_account=volc_account,
|
||||||
|
monitor_enabled=True,
|
||||||
|
).exclude(status=IAMUser.Status.DISABLED)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
spending = billing.get_spending_by_project(
|
||||||
|
bill_period, user.project_name or None
|
||||||
|
)
|
||||||
|
user.current_month_spending = spending
|
||||||
|
user.spending_updated_at = datetime.now()
|
||||||
|
user.save(update_fields=['current_month_spending', 'spending_updated_at'])
|
||||||
|
|
||||||
|
disable_threshold = user.get_disable_threshold()
|
||||||
|
alert_threshold = user.get_alert_threshold()
|
||||||
|
|
||||||
|
# Check disable threshold
|
||||||
|
if (user.auto_disable_enabled
|
||||||
|
and disable_threshold
|
||||||
|
and spending >= disable_threshold):
|
||||||
|
|
||||||
|
already_disabled = AlertRecord.objects.filter(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.DISABLE,
|
||||||
|
created_at__month=datetime.now().month,
|
||||||
|
created_at__year=datetime.now().year,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if not already_disabled:
|
||||||
|
try:
|
||||||
|
iam_svc.disable_user(user.username)
|
||||||
|
user.status = IAMUser.Status.DISABLED
|
||||||
|
user.save(update_fields=['status'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停用用户 {user.username} 失败: {e}")
|
||||||
|
|
||||||
|
alert = AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.DISABLE,
|
||||||
|
title=f"子账号 {user.username} 已自动停用",
|
||||||
|
content=f"本月消费 ¥{spending:.2f},达到停用阈值 ¥{disable_threshold:.2f}",
|
||||||
|
spending_amount=spending,
|
||||||
|
threshold_amount=disable_threshold,
|
||||||
|
)
|
||||||
|
webhook = config.feishu_webhook_url
|
||||||
|
send_feishu_alert(
|
||||||
|
webhook,
|
||||||
|
"🚨 子账号已自动停用",
|
||||||
|
f"**用户**: {user.username}\n"
|
||||||
|
f"**消费**: ¥{spending:.2f}\n"
|
||||||
|
f"**阈值**: ¥{disable_threshold:.2f}\n"
|
||||||
|
f"如需恢复,请在 AirGate 管理后台操作。",
|
||||||
|
template="red",
|
||||||
|
)
|
||||||
|
alert.notified = True
|
||||||
|
alert.save(update_fields=['notified'])
|
||||||
|
|
||||||
|
# Check alert threshold
|
||||||
|
elif alert_threshold and spending >= alert_threshold:
|
||||||
|
already_alerted = AlertRecord.objects.filter(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.WARNING,
|
||||||
|
created_at__month=datetime.now().month,
|
||||||
|
created_at__year=datetime.now().year,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if not already_alerted:
|
||||||
|
alert = AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.WARNING,
|
||||||
|
title=f"子账号 {user.username} 消费告警",
|
||||||
|
content=f"本月消费 ¥{spending:.2f},达到告警阈值 ¥{alert_threshold:.2f}",
|
||||||
|
spending_amount=spending,
|
||||||
|
threshold_amount=alert_threshold,
|
||||||
|
)
|
||||||
|
webhook = config.feishu_webhook_url
|
||||||
|
send_feishu_alert(
|
||||||
|
webhook,
|
||||||
|
"⚠️ 子账号消费告警",
|
||||||
|
f"**用户**: {user.username}\n"
|
||||||
|
f"**消费**: ¥{spending:.2f}\n"
|
||||||
|
f"**告警阈值**: ¥{alert_threshold:.2f}\n"
|
||||||
|
f"**停用阈值**: ¥{disable_threshold:.2f}",
|
||||||
|
template="orange",
|
||||||
|
)
|
||||||
|
alert.notified = True
|
||||||
|
alert.save(update_fields=['notified'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"检查用户 {user.username} 消费失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""启动定时任务"""
|
||||||
|
global _scheduler_started
|
||||||
|
if _scheduler_started:
|
||||||
|
return
|
||||||
|
_scheduler_started = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
interval = getattr(settings, 'MONITOR_INTERVAL', 3600)
|
||||||
|
scheduler.add_job(check_spending, 'interval', seconds=interval,
|
||||||
|
id='check_spending', replace_existing=True)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info(f"消费监控定时任务已启动,间隔 {interval} 秒")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动定时任务失败: {e}")
|
||||||
115
backend/utils/volcengine_client.py
Normal file
115
backend/utils/volcengine_client.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""火山引擎 Open API 客户端(HMAC-SHA256 签名)"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VolcengineAPIError(Exception):
|
||||||
|
def __init__(self, action: str, code: str, message: str):
|
||||||
|
self.action = action
|
||||||
|
self.code = code
|
||||||
|
super().__init__(f"[{action}] {code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class VolcengineClient:
|
||||||
|
"""火山引擎 API 客户端"""
|
||||||
|
|
||||||
|
def __init__(self, ak: str, sk: str, service: str, host: str,
|
||||||
|
region: str = "cn-north-1", version: str = "2018-01-01"):
|
||||||
|
self.ak = ak
|
||||||
|
self.sk = sk
|
||||||
|
self.service = service
|
||||||
|
self.host = host
|
||||||
|
self.region = region
|
||||||
|
self.version = version
|
||||||
|
|
||||||
|
def _norm_query(self, params: dict) -> str:
|
||||||
|
query = ""
|
||||||
|
for key in sorted(params.keys()):
|
||||||
|
if isinstance(params[key], list):
|
||||||
|
for v in params[key]:
|
||||||
|
query += quote(key, safe="-_.~") + "=" + quote(str(v), safe="-_.~") + "&"
|
||||||
|
else:
|
||||||
|
query += quote(key, safe="-_.~") + "=" + quote(str(params[key]), safe="-_.~") + "&"
|
||||||
|
return query[:-1].replace("+", "%20") if query else ""
|
||||||
|
|
||||||
|
def _hmac_sha256(self, key: bytes, content: str) -> bytes:
|
||||||
|
return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
def _hash_sha256(self, content: str) -> str:
|
||||||
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def call(self, action: str, params: dict = None, body: str = "") -> dict:
|
||||||
|
params = params or {}
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
x_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
short_date = x_date[:8]
|
||||||
|
|
||||||
|
x_content_sha256 = self._hash_sha256(body)
|
||||||
|
all_params = {"Action": action, "Version": self.version, **params}
|
||||||
|
|
||||||
|
signed_headers_str = "content-type;host;x-content-sha256;x-date"
|
||||||
|
canonical_headers = (
|
||||||
|
f"content-type:application/x-www-form-urlencoded\n"
|
||||||
|
f"host:{self.host}\n"
|
||||||
|
f"x-content-sha256:{x_content_sha256}\n"
|
||||||
|
f"x-date:{x_date}"
|
||||||
|
)
|
||||||
|
query_string = self._norm_query(all_params)
|
||||||
|
canonical_request = "\n".join([
|
||||||
|
"GET", "/", query_string,
|
||||||
|
canonical_headers, "", signed_headers_str, x_content_sha256
|
||||||
|
])
|
||||||
|
|
||||||
|
credential_scope = f"{short_date}/{self.region}/{self.service}/request"
|
||||||
|
string_to_sign = "\n".join([
|
||||||
|
"HMAC-SHA256", x_date, credential_scope,
|
||||||
|
self._hash_sha256(canonical_request)
|
||||||
|
])
|
||||||
|
|
||||||
|
k_date = self._hmac_sha256(self.sk.encode("utf-8"), short_date)
|
||||||
|
k_region = self._hmac_sha256(k_date, self.region)
|
||||||
|
k_service = self._hmac_sha256(k_region, self.service)
|
||||||
|
k_signing = self._hmac_sha256(k_service, "request")
|
||||||
|
signature = self._hmac_sha256(k_signing, string_to_sign).hex()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Host": self.host,
|
||||||
|
"X-Date": x_date,
|
||||||
|
"X-Content-Sha256": x_content_sha256,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": (
|
||||||
|
f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, "
|
||||||
|
f"SignedHeaders={signed_headers_str}, Signature={signature}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"https://{self.host}/?{query_string}"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers=headers, timeout=30)
|
||||||
|
resp = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise VolcengineAPIError(action, "NetworkError", str(e))
|
||||||
|
|
||||||
|
error = resp.get("ResponseMetadata", {}).get("Error")
|
||||||
|
if error:
|
||||||
|
raise VolcengineAPIError(
|
||||||
|
action, error.get("Code", "Unknown"), error.get("Message", "")
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def get_iam_client(ak: str, sk: str) -> VolcengineClient:
|
||||||
|
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com")
|
||||||
|
|
||||||
|
|
||||||
|
def get_billing_client(ak: str, sk: str) -> VolcengineClient:
|
||||||
|
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
|
||||||
|
version="2022-01-01")
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1721
frontend/package-lock.json
generated
Normal file
1721
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"element-plus": "^2.13.5",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
30
frontend/src/api/index.js
Normal file
30
frontend/src/api/index.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8100',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (auth.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${auth.token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
65
frontend/src/layouts/MainLayout.vue
Normal file
65
frontend/src/layouts/MainLayout.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<el-container style="min-height: 100vh;">
|
||||||
|
<el-aside width="220px" style="background: #1d1e2c;">
|
||||||
|
<div class="logo">AirGate</div>
|
||||||
|
<el-menu :default-active="route.path" router background-color="#1d1e2c"
|
||||||
|
text-color="#a0a3bd" active-text-color="#fff">
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/iam-users">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span>子账号管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/billing">
|
||||||
|
<el-icon><Wallet /></el-icon>
|
||||||
|
<span>消费监控</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/alerts">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
<span>告警记录</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/settings">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>系统设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<el-header style="display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
background: #fff; border-bottom: 1px solid #eee; height: 56px;">
|
||||||
|
<span style="margin-right: 16px; color: #666;">{{ auth.user?.username }}</span>
|
||||||
|
<el-button text @click="handleLogout">退出登录</el-button>
|
||||||
|
</el-header>
|
||||||
|
<el-main style="background: #f5f7fa; padding: 24px;">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 20px 24px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
36
frontend/src/router/index.js
Normal file
36
frontend/src/router/index.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('../layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') },
|
||||||
|
{ path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') },
|
||||||
|
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
|
||||||
|
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') },
|
||||||
|
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!to.meta.public && !auth.isLoggedIn) {
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
30
frontend/src/stores/auth.js
Normal file
30
frontend/src/stores/auth.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('airgate_token') || '')
|
||||||
|
const refreshToken = ref(localStorage.getItem('airgate_refresh') || '')
|
||||||
|
const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null'))
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
function setAuth(data) {
|
||||||
|
token.value = data.access
|
||||||
|
refreshToken.value = data.refresh
|
||||||
|
user.value = data.user
|
||||||
|
localStorage.setItem('airgate_token', data.access)
|
||||||
|
localStorage.setItem('airgate_refresh', data.refresh)
|
||||||
|
localStorage.setItem('airgate_user', JSON.stringify(data.user))
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
refreshToken.value = ''
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('airgate_token')
|
||||||
|
localStorage.removeItem('airgate_refresh')
|
||||||
|
localStorage.removeItem('airgate_user')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, refreshToken, user, isLoggedIn, setAuth, logout }
|
||||||
|
})
|
||||||
16
frontend/src/style.css
Normal file
16
frontend/src/style.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
84
frontend/src/views/LoginView.vue
Normal file
84
frontend/src/views/LoginView.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>AirGate</h1>
|
||||||
|
<p>火山引擎 IAM 子账号管控平台</p>
|
||||||
|
</div>
|
||||||
|
<el-form :model="form" @submit.prevent="handleLogin" label-position="top">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="form.username" placeholder="admin" size="large" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="密码" size="large"
|
||||||
|
show-password @keyup.enter="handleLogin" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" @click="handleLogin"
|
||||||
|
style="width: 100%; margin-top: 8px;">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const form = ref({ username: '', password: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!form.value.username || !form.value.password) {
|
||||||
|
ElMessage.warning('请输入用户名和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/v1/auth/login/', form.value)
|
||||||
|
auth.setAuth(data)
|
||||||
|
router.push('/')
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err.response?.data?.message || '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 400px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.login-header p {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
72
frontend/src/views/alerts/AlertList.vue
Normal file
72
frontend/src/views/alerts/AlertList.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 20px;">告警记录</h2>
|
||||||
|
<el-card>
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<el-radio-group v-model="typeFilter" @change="loadAlerts">
|
||||||
|
<el-radio-button value="">全部</el-radio-button>
|
||||||
|
<el-radio-button value="warning">告警</el-radio-button>
|
||||||
|
<el-radio-button value="disable">自动停用</el-radio-button>
|
||||||
|
<el-radio-button value="manual">手动操作</el-radio-button>
|
||||||
|
<el-radio-button value="error">错误</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<el-table :data="alerts" stripe v-loading="loading" style="width: 100%;" empty-text="暂无记录">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
|
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alert_type" label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="typeMap[row.alert_type]?.tag || 'info'" size="small">
|
||||||
|
{{ typeMap[row.alert_type]?.label || row.alert_type }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="iam_username" label="子账号" width="140" />
|
||||||
|
<el-table-column prop="title" label="标题" />
|
||||||
|
<el-table-column prop="spending_amount" label="消费金额" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.spending_amount ? `¥${row.spending_amount}` : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="notified" label="已通知" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-icon :color="row.notified ? '#67c23a' : '#ccc'"><CircleCheck /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const alerts = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const typeFilter = ref('')
|
||||||
|
|
||||||
|
const typeMap = {
|
||||||
|
warning: { label: '告警', tag: 'warning' },
|
||||||
|
disable: { label: '自动停用', tag: 'danger' },
|
||||||
|
manual: { label: '手动操作', tag: '' },
|
||||||
|
error: { label: '错误', tag: 'danger' },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlerts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = typeFilter.value ? { type: typeFilter.value } : {}
|
||||||
|
const { data } = await api.get('/api/v1/alerts/', { params })
|
||||||
|
alerts.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载告警记录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAlerts)
|
||||||
|
</script>
|
||||||
115
frontend/src/views/billing/BillingView.vue
Normal file
115
frontend/src/views/billing/BillingView.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>消费监控</h2>
|
||||||
|
<div>
|
||||||
|
<el-button @click="refreshSpending" :loading="refreshing">
|
||||||
|
<el-icon><Refresh /></el-icon> 刷新消费数据
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="loadBalance">查看主账号余额</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="20" style="margin-bottom: 20px;" v-if="balance">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || 0)"
|
||||||
|
:precision="2" prefix="¥" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>各子账号本月消费</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="overview.users || []" stripe v-loading="loading" style="width:100%;"
|
||||||
|
:default-sort="{ prop: 'current_month_spending', order: 'descending' }">
|
||||||
|
<el-table-column prop="username" label="用户名" width="160" />
|
||||||
|
<el-table-column prop="display_name" label="显示名" width="140" />
|
||||||
|
<el-table-column prop="project_name" label="项目" width="160" />
|
||||||
|
<el-table-column prop="current_month_spending" label="本月消费" width="140" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-weight: 600; color: #e6a23c;">
|
||||||
|
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="告警阈值" width="120">
|
||||||
|
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="停用阈值" width="120">
|
||||||
|
<template #default="{ row }">¥{{ row.effective_disable_threshold }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消费进度" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress
|
||||||
|
:percentage="Math.min(100, (Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100))"
|
||||||
|
:color="progressColor(row)"
|
||||||
|
:stroke-width="12"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="spending_updated_at" label="更新时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.spending_updated_at ? new Date(row.spending_updated_at).toLocaleString('zh-CN') : '暂无' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const overview = ref({})
|
||||||
|
const balance = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/billing/overview/')
|
||||||
|
overview.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载消费数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSpending() {
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/v1/billing/refresh/')
|
||||||
|
ElMessage.success(data.message)
|
||||||
|
await loadOverview()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '刷新失败')
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBalance() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/billing/balance/')
|
||||||
|
balance.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '查询余额失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressColor(row) {
|
||||||
|
const pct = Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100
|
||||||
|
if (pct >= 80) return '#f56c6c'
|
||||||
|
if (pct >= 50) return '#e6a23c'
|
||||||
|
return '#67c23a'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOverview)
|
||||||
|
</script>
|
||||||
80
frontend/src/views/dashboard/DashboardView.vue
Normal file
80
frontend/src/views/dashboard/DashboardView.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px;">仪表盘</h2>
|
||||||
|
<el-row :gutter="20" style="margin-bottom: 24px;">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="子账号总数" :value="data.total_users" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="正常运行" :value="data.active_users">
|
||||||
|
<template #suffix><span style="color:#67c23a;font-size:14px;"> 个</span></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="已停用" :value="data.disabled_users">
|
||||||
|
<template #suffix><span style="color:#f56c6c;font-size:14px;"> 个</span></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="本月总消费" :value="Number(data.total_spending)" :precision="2"
|
||||||
|
prefix="¥" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>最近告警</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="data.recent_alerts" stripe style="width: 100%;" empty-text="暂无告警">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alert_type" label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.alert_type === 'disable' ? 'danger' : row.alert_type === 'warning' ? 'warning' : 'info'" size="small">
|
||||||
|
{{ row.alert_type === 'disable' ? '停用' : row.alert_type === 'warning' ? '告警' : row.alert_type }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="标题" />
|
||||||
|
<el-table-column prop="spending_amount" label="消费" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.spending_amount ? `¥${row.spending_amount}` : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const data = ref({
|
||||||
|
total_users: 0, active_users: 0, disabled_users: 0,
|
||||||
|
monitored_users: 0, total_spending: '0', recent_alerts: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/v1/dashboard/')
|
||||||
|
data.value = res.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load dashboard:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
return new Date(t).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
199
frontend/src/views/iam/IAMUserList.vue
Normal file
199
frontend/src/views/iam/IAMUserList.vue
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>子账号管理</h2>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="handleSync" :loading="syncing">
|
||||||
|
<el-icon><Refresh /></el-icon> 同步火山用户
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="users" stripe v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="username" label="用户名" width="160" />
|
||||||
|
<el-table-column prop="display_name" label="显示名" width="140" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small">
|
||||||
|
{{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="project_name" label="关联项目" width="140" />
|
||||||
|
<el-table-column label="本月消费" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: Number(row.current_month_spending) > 0 ? '#e6a23c' : '' }">
|
||||||
|
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="告警阈值" width="110">
|
||||||
|
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="停用阈值" width="110">
|
||||||
|
<template #default="{ row }">¥{{ row.effective_disable_threshold }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="监控" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.monitor_enabled ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.monitor_enabled ? '开' : '关' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openConfig(row)">配置</el-button>
|
||||||
|
<el-button v-if="row.status === 'active'" size="small" type="danger" @click="handleDisable(row)">停用</el-button>
|
||||||
|
<el-button v-if="row.status === 'disabled'" size="small" type="success" @click="handleEnable(row)">恢复</el-button>
|
||||||
|
<el-button size="small" @click="viewPolicies(row)">权限</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Config Dialog -->
|
||||||
|
<el-dialog v-model="configVisible" title="阈值配置" width="500px">
|
||||||
|
<el-form :model="configForm" label-width="120px">
|
||||||
|
<el-form-item label="告警阈值(元)">
|
||||||
|
<el-input-number v-model="configForm.alert_threshold" :min="0" :precision="2" style="width:100%;" />
|
||||||
|
<div class="form-hint">留空则使用全局默认值</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="停用阈值(元)">
|
||||||
|
<el-input-number v-model="configForm.disable_threshold" :min="0" :precision="2" style="width:100%;" />
|
||||||
|
<div class="form-hint">留空则使用全局默认值</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="消费监控">
|
||||||
|
<el-switch v-model="configForm.monitor_enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自动停用">
|
||||||
|
<el-switch v-model="configForm.auto_disable_enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="configVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveConfig" :loading="saving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Policies Dialog -->
|
||||||
|
<el-dialog v-model="policiesVisible" title="权限策略" width="600px">
|
||||||
|
<el-table :data="policies" stripe v-loading="policiesLoading">
|
||||||
|
<el-table-column prop="PolicyName" label="策略名" />
|
||||||
|
<el-table-column prop="PolicyType" label="类型" width="100" />
|
||||||
|
<el-table-column prop="Description" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
|
||||||
|
const configVisible = ref(false)
|
||||||
|
const configForm = ref({})
|
||||||
|
const configUserId = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const policiesVisible = ref(false)
|
||||||
|
const policies = ref([])
|
||||||
|
const policiesLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/iam-users/')
|
||||||
|
users.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载用户列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/v1/iam-users/sync/')
|
||||||
|
ElMessage.success(data.message)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '同步失败')
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable(row) {
|
||||||
|
await ElMessageBox.confirm(`确定要停用子账号 "${row.username}" 吗?停用后该账号的控制台和 API 访问都将被禁止。`, '确认停用', { type: 'warning' })
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/iam-users/${row.id}/disable/`)
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '停用失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEnable(row) {
|
||||||
|
await ElMessageBox.confirm(`确定要恢复子账号 "${row.username}" 吗?`, '确认恢复')
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/iam-users/${row.id}/enable/`)
|
||||||
|
ElMessage.success('已恢复')
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '恢复失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfig(row) {
|
||||||
|
configUserId.value = row.id
|
||||||
|
configForm.value = {
|
||||||
|
alert_threshold: row.alert_threshold ? Number(row.alert_threshold) : null,
|
||||||
|
disable_threshold: row.disable_threshold ? Number(row.disable_threshold) : null,
|
||||||
|
monitor_enabled: row.monitor_enabled,
|
||||||
|
auto_disable_enabled: row.auto_disable_enabled,
|
||||||
|
}
|
||||||
|
configVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/iam-users/${configUserId.value}/update/`, configForm.value)
|
||||||
|
ElMessage.success('配置已保存')
|
||||||
|
configVisible.value = false
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewPolicies(row) {
|
||||||
|
policiesVisible.value = true
|
||||||
|
policiesLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
|
||||||
|
policies.value = data.policies || []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '获取权限失败')
|
||||||
|
policies.value = []
|
||||||
|
} finally {
|
||||||
|
policiesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadUsers)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-hint { font-size: 12px; color: #999; margin-top: 4px; }
|
||||||
|
</style>
|
||||||
171
frontend/src/views/settings/SettingsView.vue
Normal file
171
frontend/src/views/settings/SettingsView.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 20px;">系统设置</h2>
|
||||||
|
|
||||||
|
<!-- Global Config -->
|
||||||
|
<el-card style="margin-bottom: 20px;">
|
||||||
|
<template #header><span>全局默认配置</span></template>
|
||||||
|
<el-form :model="config" label-width="180px" v-loading="loadingConfig">
|
||||||
|
<el-form-item label="默认告警阈值(元)">
|
||||||
|
<el-input-number v-model="config.default_alert_threshold" :min="0" :precision="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认停用阈值(元)">
|
||||||
|
<el-input-number v-model="config.default_disable_threshold" :min="0" :precision="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="监控间隔(秒)">
|
||||||
|
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="飞书 Webhook URL">
|
||||||
|
<el-input v-model="config.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="飞书通知手机号">
|
||||||
|
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号1,手机号2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Volcengine Account -->
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span>火山引擎主账号</span>
|
||||||
|
<el-button size="small" type="primary" @click="showAddAccount = true">添加主账号</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="accounts" stripe style="width:100%;" v-loading="loadingAccounts">
|
||||||
|
<el-table-column prop="name" label="名称" width="200" />
|
||||||
|
<el-table-column prop="access_key_hint" label="AccessKey" width="200" />
|
||||||
|
<el-table-column prop="is_active" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="testAccount(row.id)">测试</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteAccount(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Add Account Dialog -->
|
||||||
|
<el-dialog v-model="showAddAccount" title="添加火山主账号" width="500px">
|
||||||
|
<el-form :model="accountForm" label-width="120px">
|
||||||
|
<el-form-item label="账号名称">
|
||||||
|
<el-input v-model="accountForm.name" placeholder="如:主账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AccessKey">
|
||||||
|
<el-input v-model="accountForm.access_key" placeholder="AKLT..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SecretKey">
|
||||||
|
<el-input v-model="accountForm.secret_key" type="password" show-password
|
||||||
|
placeholder="输入后加密存储,不可回显" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddAccount = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="addAccount" :loading="addingAccount">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
// Global config
|
||||||
|
const config = ref({})
|
||||||
|
const loadingConfig = ref(false)
|
||||||
|
const savingConfig = ref(false)
|
||||||
|
|
||||||
|
// Volc accounts
|
||||||
|
const accounts = ref([])
|
||||||
|
const loadingAccounts = ref(false)
|
||||||
|
const showAddAccount = ref(false)
|
||||||
|
const accountForm = ref({ name: '默认主账号', access_key: '', secret_key: '' })
|
||||||
|
const addingAccount = ref(false)
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
loadingConfig.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/config/')
|
||||||
|
config.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载配置失败')
|
||||||
|
} finally {
|
||||||
|
loadingConfig.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
savingConfig.value = true
|
||||||
|
try {
|
||||||
|
await api.put('/api/v1/config/', config.value)
|
||||||
|
ElMessage.success('配置已保存')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
savingConfig.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
loadingAccounts.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/volc-accounts/')
|
||||||
|
accounts.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载主账号失败')
|
||||||
|
} finally {
|
||||||
|
loadingAccounts.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAccount() {
|
||||||
|
addingAccount.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/api/v1/volc-accounts/', accountForm.value)
|
||||||
|
ElMessage.success('主账号已添加')
|
||||||
|
showAddAccount.value = false
|
||||||
|
accountForm.value = { name: '默认主账号', access_key: '', secret_key: '' }
|
||||||
|
await loadAccounts()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('添加失败')
|
||||||
|
} finally {
|
||||||
|
addingAccount.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAccount(id) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/api/v1/volc-accounts/${id}/test/`)
|
||||||
|
ElMessage.success(data.message)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount(id) {
|
||||||
|
await ElMessageBox.confirm('确定删除此主账号配置?', '确认删除', { type: 'warning' })
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/v1/volc-accounts/${id}/`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadAccounts()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfig()
|
||||||
|
loadAccounts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8100',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1218
火山引擎IAM子账号管控工具_深度研究报告.md
Normal file
1218
火山引擎IAM子账号管控工具_深度研究报告.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user