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:
seaislee1209 2026-03-19 13:03:30 +08:00
commit 555c86ce76
59 changed files with 5818 additions and 0 deletions

34
.env.example Normal file
View 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
View 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
View File

View File

View 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')

View 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 = '管理员账户'

View 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()),
],
),
]

View 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

View 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()

View 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),
]

View 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)

View File

View 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')

View 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()

View 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')},
},
),
]

View 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}"

View 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)

View 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)

View 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),
]

View 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)

View File

147
backend/config/settings.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

View 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
View 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
View 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()

View 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
View 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}")

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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"
}
}

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
View 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
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

30
frontend/src/api/index.js Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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
View 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')

View 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

View 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
View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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,
},
},
},
})

File diff suppressed because it is too large Load Diff