feat: complete AirGate core features + full audit fixes

Quota allocation system:
- Replace monthly budget with one-time quota allocation (prepaid model)
- Support both adding (+) and deducting (-) quota with underflow protection
- Stepped alerts at configurable percentages (e.g., 50%/80%/90%)
- Auto-disable when quota exhausted (100%), alert state resets on new allocation
- Quota allocation history with operator audit trail

IAM management:
- Create new IAM sub-accounts directly from AirGate (auto-generates API keys)
- SecretKey shown once in dialog with copy-to-clipboard
- Attach/detach IAM policies via UI (ArkFullAccess, TOSFullAccess, etc.)
- Sync existing users from Volcengine
- Project list pulled from Volcengine API for dropdown selection

Security & auth:
- API Key authentication for external systems (AirDrama integration)
- SECRET_KEY enforced in production (raises error if missing with DEBUG=False)
- APIKeyUser with proper pk/is_staff attributes for DRF compatibility

Infrastructure:
- Docker + docker-compose for backend and frontend
- Nginx reverse proxy for frontend with /api/ forwarding
- Entrypoint with auto-migrate and default admin creation
- SQLite data persisted via Docker volume at /app/data/

Bug fixes from audit:
- Fix frontend referencing non-existent fields (current_month_spending, effective_budget, budget_usage_percent)
- Fix scheduler using naive datetime.now() → timezone.now()
- Fix scheduler reading interval from settings instead of GlobalConfig DB
- Fix docker-compose SQLite volume mounting as directory
- Fix CORS origin with explicit port 80
- Remove dead config (VOLC_ACCESS_KEY/SK, MONITOR_INTERVAL from settings)
- Remove unused imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-19 15:08:33 +08:00
parent 555c86ce76
commit 3213d6d98a
25 changed files with 1309 additions and 372 deletions

View File

@ -16,19 +16,16 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# DB_USER= # DB_USER=
# DB_PASSWORD= # DB_PASSWORD=
# 火山引擎主账号密钥(必填,用于管理 IAM 子账号) # 数据加密密钥(用于加密存储火山主账号 AK/SK
VOLC_ACCESS_KEY=
VOLC_SECRET_KEY=
# 数据加密密钥(用于加密存储在数据库中的密钥)
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRGATE_ENCRYPTION_KEY= AIRGATE_ENCRYPTION_KEY=
# 飞书机器人 Webhook选填也可在管理界面中配置 # 飞书机器人 Webhook选填也可在管理界面「系统设置」中配置
FEISHU_WEBHOOK_URL= # FEISHU_WEBHOOK_URL=
# AirGate API Key供外部系统如 AirDrama 调用本系统 API 时使用) # AirGate API Key供外部系统如 AirDrama 调用本系统 API 时使用)
AIRGATE_API_KEY=change-me-to-a-random-api-key # 不设置则 API Key 认证不生效,仅 JWT 认证可用
# AIRGATE_API_KEY=
# 消费监控检查间隔(秒,默认 3600 = 1小时 # 注意:火山主账号 AK/SK 不要写在这里!
MONITOR_INTERVAL=3600 # 启动后在浏览器「系统设置 → 添加主账号」中填入,会加密存入数据库。

26
backend/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV GUNICORN_RUNNING=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc default-libmysqlclient-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput 2>/dev/null || true
EXPOSE 8100
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:8100", "--workers", "2", "--timeout", "120", "config.wsgi:application"]

View File

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
@admin.register(VolcAccount) @admin.register(VolcAccount)
@ -10,13 +10,18 @@ class VolcAccountAdmin(admin.ModelAdmin):
@admin.register(IAMUser) @admin.register(IAMUser)
class IAMUserAdmin(admin.ModelAdmin): class IAMUserAdmin(admin.ModelAdmin):
list_display = ('username', 'display_name', 'status', 'monitor_enabled', list_display = ('username', 'display_name', 'status', 'monitor_enabled',
'current_month_spending', 'alert_threshold', 'disable_threshold') 'allocated_quota', 'consumed_total')
list_filter = ('status', 'monitor_enabled') list_filter = ('status', 'monitor_enabled')
@admin.register(QuotaAllocation)
class QuotaAllocationAdmin(admin.ModelAdmin):
list_display = ('iam_user', 'amount', 'total_after', 'created_by', 'created_at')
@admin.register(GlobalConfig) @admin.register(GlobalConfig)
class GlobalConfigAdmin(admin.ModelAdmin): class GlobalConfigAdmin(admin.ModelAdmin):
list_display = ('default_alert_threshold', 'default_disable_threshold', 'monitor_interval_seconds') list_display = ('monitor_interval_seconds', 'updated_at')
@admin.register(AlertRecord) @admin.register(AlertRecord)

View File

@ -0,0 +1,59 @@
# Generated by Django 4.2.21 on 2026-03-19 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('monitor', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='globalconfig',
name='default_alert_threshold',
),
migrations.RemoveField(
model_name='globalconfig',
name='default_disable_threshold',
),
migrations.RemoveField(
model_name='iamuser',
name='alert_threshold',
),
migrations.RemoveField(
model_name='iamuser',
name='disable_threshold',
),
migrations.AddField(
model_name='globalconfig',
name='default_alert_thresholds',
field=models.JSONField(blank=True, default=list, help_text='如 [50, 80, 90],达到预算的对应百分比时告警', verbose_name='默认告警阈值(百分比列表)'),
),
migrations.AddField(
model_name='globalconfig',
name='default_monthly_budget',
field=models.DecimalField(decimal_places=2, default=100000, max_digits=12, verbose_name='默认月度预算(元)'),
),
migrations.AddField(
model_name='iamuser',
name='alert_thresholds',
field=models.JSONField(blank=True, default=list, help_text='如 [50, 80, 90] 表示达到预算的 50%/80%/90% 时告警', verbose_name='告警阈值(百分比列表)'),
),
migrations.AddField(
model_name='iamuser',
name='disable_at_budget',
field=models.BooleanField(default=True, help_text='消费达到月度预算 100% 时自动停用', verbose_name='达到预算自动停用'),
),
migrations.AddField(
model_name='iamuser',
name='monthly_budget',
field=models.DecimalField(blank=True, decimal_places=2, help_text='本月预期消费总额,留空则使用全局默认值', max_digits=12, null=True, verbose_name='月度预算(元)'),
),
migrations.AddField(
model_name='iamuser',
name='triggered_alerts',
field=models.JSONField(blank=True, default=list, help_text='记录本月已通知过的百分比,避免重复告警', verbose_name='本月已触发的阈值'),
),
]

View File

@ -0,0 +1,78 @@
# Generated by Django 4.2.21 on 2026-03-19 06:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('monitor', '0002_remove_globalconfig_default_alert_threshold_and_more'),
]
operations = [
migrations.RemoveField(
model_name='globalconfig',
name='default_monthly_budget',
),
migrations.RemoveField(
model_name='iamuser',
name='current_month_spending',
),
migrations.RemoveField(
model_name='iamuser',
name='disable_at_budget',
),
migrations.RemoveField(
model_name='iamuser',
name='monthly_budget',
),
migrations.AddField(
model_name='iamuser',
name='allocated_quota',
field=models.DecimalField(decimal_places=2, default=0, help_text='主账号累计划拨给此子账号的总额度', max_digits=12, verbose_name='已划拨额度(元)'),
),
migrations.AddField(
model_name='iamuser',
name='consumed_total',
field=models.DecimalField(decimal_places=2, default=0, help_text='从 Billing API 获取的累计消费总额', max_digits=12, verbose_name='累计消费(元)'),
),
migrations.AlterField(
model_name='globalconfig',
name='default_alert_thresholds',
field=models.JSONField(blank=True, default=list, help_text='如 [50, 80, 90]', verbose_name='默认告警阈值(百分比列表)'),
),
migrations.AlterField(
model_name='iamuser',
name='alert_thresholds',
field=models.JSONField(blank=True, default=list, help_text='如 [50, 80, 90] 表示消费达到额度的 50%/80%/90% 时告警', verbose_name='告警阈值(百分比列表)'),
),
migrations.AlterField(
model_name='iamuser',
name='auto_disable_enabled',
field=models.BooleanField(default=True, help_text='消费达到已划拨额度 100% 时自动停用', verbose_name='额度用尽自动停用'),
),
migrations.AlterField(
model_name='iamuser',
name='triggered_alerts',
field=models.JSONField(blank=True, default=list, help_text='记录已通知过的百分比,划拨新额度时自动重置', verbose_name='已触发的告警阈值'),
),
migrations.CreateModel(
name='QuotaAllocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='划拨金额(元)')),
('total_after', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='划拨后总额度')),
('note', models.CharField(blank=True, max_length=500, verbose_name='备注')),
('created_by', models.CharField(blank=True, max_length=100, verbose_name='操作人')),
('created_at', models.DateTimeField(auto_now_add=True)),
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_allocations', to='monitor.iamuser')),
],
options={
'verbose_name': '额度划拨记录',
'verbose_name_plural': '额度划拨记录',
'db_table': 'airgate_quota_allocation',
'ordering': ['-created_at'],
},
),
]

View File

@ -1,3 +1,4 @@
from decimal import Decimal
from django.db import models from django.db import models
@ -41,18 +42,22 @@ class IAMUser(models.Model):
# Access keys (stored as JSON list of AK IDs, not secrets) # Access keys (stored as JSON list of AK IDs, not secrets)
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
# Monitoring config # --- 额度管理(划拨制) ---
monitor_enabled = models.BooleanField('启用消费监控', default=True) allocated_quota = models.DecimalField('已划拨额度(元)', max_digits=12, decimal_places=2, default=0,
auto_disable_enabled = models.BooleanField('启用自动停用', default=True) help_text='主账号累计划拨给此子账号的总额度')
alert_threshold = models.DecimalField('告警阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True, consumed_total = models.DecimalField('累计消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='为空则使用全局默认值') help_text='从 Billing API 获取的累计消费总额')
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) spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
# --- 监控配置 ---
monitor_enabled = models.BooleanField('启用消费监控', default=True)
alert_thresholds = models.JSONField('告警阈值(百分比列表)', default=list, blank=True,
help_text='如 [50, 80, 90] 表示消费达到额度的 50%/80%/90% 时告警')
auto_disable_enabled = models.BooleanField('额度用尽自动停用', default=True,
help_text='消费达到已划拨额度 100% 时自动停用')
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
help_text='记录已通知过的百分比,划拨新额度时自动重置')
remark = models.TextField('备注', blank=True) remark = models.TextField('备注', blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -66,23 +71,48 @@ class IAMUser(models.Model):
def __str__(self): def __str__(self):
return f"{self.display_name or self.username} ({self.status})" return f"{self.display_name or self.username} ({self.status})"
def get_alert_threshold(self): @property
if self.alert_threshold is not None: def remaining_quota(self):
return self.alert_threshold """剩余额度"""
config = GlobalConfig.get_solo() return max(Decimal('0'), self.allocated_quota - self.consumed_total)
return config.default_alert_threshold
def get_disable_threshold(self): @property
if self.disable_threshold is not None: def usage_percent(self):
return self.disable_threshold """额度使用率"""
if self.allocated_quota <= 0:
return 0
return round(float(self.consumed_total) / float(self.allocated_quota) * 100, 1)
def get_alert_thresholds(self):
if self.alert_thresholds:
return sorted(self.alert_thresholds)
config = GlobalConfig.get_solo() config = GlobalConfig.get_solo()
return config.default_disable_threshold return sorted(config.default_alert_thresholds or [50, 80, 90])
class QuotaAllocation(models.Model):
"""额度划拨记录"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
amount = models.DecimalField('变更金额(元,正=追加,负=扣减)', max_digits=12, decimal_places=2)
total_after = models.DecimalField('划拨后总额度', max_digits=12, decimal_places=2)
note = models.CharField('备注', max_length=500, blank=True)
created_by = models.CharField('操作人', max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '额度划拨记录'
verbose_name_plural = '额度划拨记录'
db_table = 'airgate_quota_allocation'
ordering = ['-created_at']
def __str__(self):
return f"{self.iam_user.username}{self.amount} → ¥{self.total_after}"
class GlobalConfig(models.Model): class GlobalConfig(models.Model):
"""全局配置(单例)""" """全局配置(单例)"""
default_alert_threshold = models.DecimalField('默认告警阈值(元)', max_digits=12, decimal_places=2, default=1000) default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
default_disable_threshold = models.DecimalField('默认停用阈值(元)', max_digits=12, decimal_places=2, default=5000) help_text='如 [50, 80, 90]')
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600) monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600)
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True) feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True) feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)

View File

@ -1,11 +1,43 @@
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from django.conf import settings from django.conf import settings
class APIKeyUser:
"""Represents an external system authenticated via API Key"""
pk = None
id = None
is_authenticated = True
is_active = True
is_staff = False
is_superuser = False
username = 'api_key_user'
def __str__(self):
return 'APIKeyUser'
class APIKeyAuthentication(BaseAuthentication):
"""Authenticate requests via X-API-Key header (for external systems like AirDrama)"""
def authenticate(self, request):
api_key = request.headers.get('X-API-Key', '')
if not api_key:
return None # Let other auth methods try
expected = settings.AIRGATE_API_KEY
if not expected:
return None
if api_key != expected:
raise AuthenticationFailed('API Key 无效')
return (APIKeyUser(), None)
class IsAPIKeyAuth(BasePermission): class IsAPIKeyAuth(BasePermission):
"""允许通过 X-API-Key 头认证(供外部系统如 AirDrama 调用)""" """Permission check: require API Key authentication"""
def has_permission(self, request, view): def has_permission(self, request, view):
api_key = request.headers.get('X-API-Key', '') return isinstance(request.user, APIKeyUser)
expected = settings.AIRGATE_API_KEY
return bool(expected and api_key == expected)

View File

@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
class VolcAccountSerializer(serializers.ModelSerializer): class VolcAccountSerializer(serializers.ModelSerializer):
@ -16,29 +16,29 @@ class VolcAccountCreateSerializer(serializers.Serializer):
class IAMUserSerializer(serializers.ModelSerializer): class IAMUserSerializer(serializers.ModelSerializer):
effective_alert_threshold = serializers.SerializerMethodField() remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
effective_disable_threshold = serializers.SerializerMethodField() usage_percent = serializers.FloatField(read_only=True)
effective_alert_thresholds = serializers.SerializerMethodField()
class Meta: class Meta:
model = IAMUser model = IAMUser
fields = [ fields = [
'id', 'username', 'display_name', 'user_id', 'email', 'phone', 'id', 'username', 'display_name', 'user_id', 'email', 'phone',
'project_name', 'status', 'access_key_ids', 'project_name', 'status', 'access_key_ids',
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
'spending_updated_at',
'monitor_enabled', 'auto_disable_enabled', 'monitor_enabled', 'auto_disable_enabled',
'alert_threshold', 'disable_threshold', 'alert_thresholds', 'triggered_alerts',
'effective_alert_threshold', 'effective_disable_threshold', 'effective_alert_thresholds',
'current_month_spending', 'spending_updated_at',
'remark', 'created_at', 'updated_at', 'remark', 'created_at', 'updated_at',
] ]
read_only_fields = ['user_id', 'access_key_ids', 'status', read_only_fields = ['user_id', 'access_key_ids', 'status',
'current_month_spending', 'spending_updated_at', 'consumed_total', 'spending_updated_at',
'triggered_alerts',
'created_at', 'updated_at'] 'created_at', 'updated_at']
def get_effective_alert_threshold(self, obj): def get_effective_alert_thresholds(self, obj):
return str(obj.get_alert_threshold()) return obj.get_alert_thresholds()
def get_effective_disable_threshold(self, obj):
return str(obj.get_disable_threshold())
class IAMUserCreateSerializer(serializers.Serializer): class IAMUserCreateSerializer(serializers.Serializer):
@ -48,30 +48,46 @@ class IAMUserCreateSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=20, required=False, default='') phone = serializers.CharField(max_length=20, required=False, default='')
password = serializers.CharField(write_only=True, required=False, default='') password = serializers.CharField(write_only=True, required=False, default='')
project_name = serializers.CharField(max_length=200, 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): class IAMUserImportSerializer(serializers.Serializer):
username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名') username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名')
class IAMUserThresholdSerializer(serializers.Serializer): class IAMUserConfigSerializer(serializers.Serializer):
alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, """子账号配置更新"""
required=False, allow_null=True) project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, alert_thresholds = serializers.ListField(
required=False, allow_null=True) child=serializers.IntegerField(min_value=1, max_value=99),
required=False,
)
monitor_enabled = serializers.BooleanField(required=False) monitor_enabled = serializers.BooleanField(required=False)
auto_disable_enabled = serializers.BooleanField(required=False) auto_disable_enabled = serializers.BooleanField(required=False)
class QuotaAllocateSerializer(serializers.Serializer):
"""额度变更:正数=追加,负数=扣减"""
amount = serializers.DecimalField(max_digits=12, decimal_places=2)
note = serializers.CharField(max_length=500, required=False, default='')
def validate_amount(self, value):
from decimal import Decimal
if value == Decimal('0'):
raise serializers.ValidationError('变更金额不能为 0')
return value
class QuotaAllocationSerializer(serializers.ModelSerializer):
class Meta:
model = QuotaAllocation
fields = ['id', 'amount', 'total_after', 'note', 'created_by', 'created_at']
class GlobalConfigSerializer(serializers.ModelSerializer): class GlobalConfigSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = GlobalConfig model = GlobalConfig
fields = [ fields = [
'default_alert_threshold', 'default_disable_threshold', 'default_alert_thresholds',
'monitor_interval_seconds', 'monitor_interval_seconds',
'feishu_webhook_url', 'feishu_alert_mobiles', 'feishu_webhook_url', 'feishu_alert_mobiles',
'updated_at', 'updated_at',

View File

@ -12,6 +12,7 @@ urlpatterns = [
# IAM user management # IAM user management
path('iam-users/', views.iam_user_list_view), path('iam-users/', views.iam_user_list_view),
path('iam-users/create/', views.iam_user_create_view),
path('iam-users/sync/', views.iam_user_sync_view), path('iam-users/sync/', views.iam_user_sync_view),
path('iam-users/import/', views.iam_user_import_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>/', views.iam_user_detail_view),
@ -19,6 +20,10 @@ urlpatterns = [
path('iam-users/<int:pk>/disable/', views.iam_user_disable_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>/enable/', views.iam_user_enable_view),
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view), path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view),
path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view),
path('iam-users/<int:pk>/allocate/', views.quota_allocate_view),
path('iam-users/<int:pk>/quota-history/', views.quota_history_view),
# Billing # Billing
path('billing/overview/', views.spending_overview_view), path('billing/overview/', views.spending_overview_view),
@ -30,4 +35,7 @@ urlpatterns = [
# Alerts # Alerts
path('alerts/', views.alert_list_view), path('alerts/', views.alert_list_view),
# Projects
path('projects/', views.project_list_view),
] ]

View File

@ -7,19 +7,18 @@ from decimal import Decimal
from django.db.models import Sum from django.db.models import Sum
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from utils.crypto import encrypt, decrypt, make_hint from utils.crypto import encrypt, decrypt, make_hint
from utils.iam_service import IAMService from utils.iam_service import IAMService, ProjectService
from utils.billing_service import BillingService from utils.billing_service import BillingService
from utils.volcengine_client import VolcengineAPIError from utils.volcengine_client import VolcengineAPIError
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .serializers import ( from .serializers import (
VolcAccountSerializer, VolcAccountCreateSerializer, VolcAccountSerializer, VolcAccountCreateSerializer,
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
IAMUserThresholdSerializer, IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
GlobalConfigSerializer, GlobalConfigSerializer,
AlertRecordSerializer, AlertRecordSerializer,
DashboardSerializer, DashboardSerializer,
@ -50,7 +49,7 @@ def dashboard_view(request):
disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count() disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count()
monitored = IAMUser.objects.filter(monitor_enabled=True).count() monitored = IAMUser.objects.filter(monitor_enabled=True).count()
total_spending = IAMUser.objects.aggregate( total_spending = IAMUser.objects.aggregate(
total=Sum('current_month_spending'))['total'] or Decimal('0') total=Sum('consumed_total'))['total'] or Decimal('0')
recent_alerts = AlertRecord.objects.all()[:10] recent_alerts = AlertRecord.objects.all()[:10]
data = { data = {
@ -210,6 +209,88 @@ def iam_user_sync_view(request):
}) })
@api_view(['POST'])
def iam_user_create_view(request):
"""在火山引擎创建新的 IAM 子账号并纳入管理"""
serializer = IAMUserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
d = serializer.validated_data
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)
# 1. Create user on Volcengine
try:
resp = svc.create_user(
username=d['username'],
display_name=d.get('display_name', ''),
email=d.get('email', ''),
phone=d.get('phone', ''),
)
except VolcengineAPIError as e:
return Response({'error': 'create_failed', 'message': f'创建用户失败: {e}'},
status=status.HTTP_502_BAD_GATEWAY)
volc_user = resp.get("Result", {}).get("User", {})
result_info = {'username': d['username']}
# 2. Create login profile if password provided
password = d.get('password', '')
if password:
try:
svc.create_login_profile(d['username'], password)
result_info['login_enabled'] = True
except VolcengineAPIError as e:
result_info['login_error'] = str(e)
# 3. Create access key
try:
ak_resp = svc.create_access_key(d['username'])
ak_data = ak_resp.get("Result", {}).get("AccessKey", {})
result_info['access_key_id'] = ak_data.get("AccessKeyId", "")
result_info['secret_access_key'] = ak_data.get("SecretAccessKey", "")
result_info['secret_key_warning'] = "SecretAccessKey 仅此一次显示,请立即保存!"
except VolcengineAPIError as e:
result_info['access_key_error'] = str(e)
# 4. Attach basic policies
for policy in ['AccessKeySelfManageAccess']:
try:
svc.attach_user_policy(d['username'], policy, 'System')
except VolcengineAPIError:
pass
# 5. Save to local DB
obj = IAMUser.objects.create(
volc_account=account,
username=d['username'],
display_name=d.get('display_name', ''),
user_id=volc_user.get("UserId", ""),
email=d.get('email', ''),
phone=d.get('phone', ''),
project_name=d.get('project_name', ''),
status=IAMUser.Status.ACTIVE,
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [],
)
AlertRecord.objects.create(
iam_user=obj,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"创建子账号 {d['username']}",
content=f"操作人: {request.user.username}",
)
return Response({
'message': f"子账号 {d['username']} 创建成功",
'user': IAMUserSerializer(obj).data,
'volcengine': result_info,
}, status=status.HTTP_201_CREATED)
@api_view(['POST']) @api_view(['POST'])
def iam_user_import_view(request): def iam_user_import_view(request):
"""导入指定的已有 IAM 用户""" """导入指定的已有 IAM 用户"""
@ -263,7 +344,7 @@ def iam_user_update_view(request, pk):
except IAMUser.DoesNotExist: except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
serializer = IAMUserThresholdSerializer(data=request.data, partial=True) serializer = IAMUserConfigSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
for field, value in serializer.validated_data.items(): for field, value in serializer.validated_data.items():
@ -352,13 +433,147 @@ def iam_user_policies_view(request, pk):
status=status.HTTP_502_BAD_GATEWAY) status=status.HTTP_502_BAD_GATEWAY)
@api_view(['POST'])
def iam_user_attach_policy_view(request, pk):
"""给子账号附加权限策略"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
policy_name = request.data.get('policy_name', '')
policy_type = request.data.get('policy_type', 'System')
if not policy_name:
return Response({'error': 'missing_policy_name', 'message': '请指定策略名'},
status=status.HTTP_400_BAD_REQUEST)
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.attach_user_policy(user.username, policy_name, policy_type)
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"附加策略 {policy_name}{user.username}",
content=f"操作人: {request.user.username},策略类型: {policy_type}",
)
return Response({'message': f'已附加策略 {policy_name}'})
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)
@api_view(['POST'])
def iam_user_detach_policy_view(request, pk):
"""从子账号移除权限策略"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
policy_name = request.data.get('policy_name', '')
policy_type = request.data.get('policy_type', 'System')
if not policy_name:
return Response({'error': 'missing_policy_name', 'message': '请指定策略名'},
status=status.HTTP_400_BAD_REQUEST)
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.detach_user_policy(user.username, policy_name, policy_type)
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"移除策略 {policy_name}{user.username}",
content=f"操作人: {request.user.username},策略类型: {policy_type}",
)
return Response({'message': f'已移除策略 {policy_name}'})
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)
# ==================== Quota Allocation ====================
@api_view(['POST'])
def quota_allocate_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 = QuotaAllocateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
amount = serializer.validated_data['amount']
note = serializer.validated_data.get('note', '')
new_total = user.allocated_quota + amount
# 扣减保护:总额度不能低于已消费金额
if new_total < user.consumed_total:
return Response({
'error': 'quota_underflow',
'message': (
f'扣减后总额度 ¥{new_total:.2f} 低于已消费 ¥{user.consumed_total:.2f}'
f'最多可扣减 ¥{user.allocated_quota - user.consumed_total:.2f}'
),
}, status=status.HTTP_400_BAD_REQUEST)
user.allocated_quota = new_total
user.triggered_alerts = [] # 额度变更时重置告警状态
user.save(update_fields=['allocated_quota', 'triggered_alerts'])
is_deduct = amount < 0
action_label = f"扣减额度 ¥{abs(amount)}" if is_deduct else f"追加额度 ¥{amount}"
allocation = QuotaAllocation.objects.create(
iam_user=user,
amount=amount,
total_after=user.allocated_quota,
note=note,
created_by=request.user.username,
)
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"{action_label}{user.username}",
content=f"操作人: {request.user.username},变更后总额度: ¥{user.allocated_quota},备注: {note}",
)
return Response({
'message': f'{action_label},当前总额度 ¥{user.allocated_quota}',
'user': IAMUserSerializer(user).data,
'allocation': QuotaAllocationSerializer(allocation).data,
})
@api_view(['GET'])
def quota_history_view(request, pk):
"""查看子账号的额度划拨历史"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
allocations = QuotaAllocation.objects.filter(iam_user=user)
return Response(QuotaAllocationSerializer(allocations, many=True).data)
# ==================== Billing ==================== # ==================== Billing ====================
@api_view(['GET']) @api_view(['GET'])
def spending_overview_view(request): def spending_overview_view(request):
"""消费总览""" """消费总览"""
bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m")) bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m"))
users = IAMUser.objects.all().order_by('-current_month_spending') users = IAMUser.objects.all().order_by('-consumed_total')
return Response({ return Response({
'period': bill_period, 'period': bill_period,
'users': IAMUserSerializer(users, many=True).data, 'users': IAMUserSerializer(users, many=True).data,
@ -417,3 +632,29 @@ def alert_list_view(request):
alerts = alerts.filter(alert_type=alert_type) alerts = alerts.filter(alert_type=alert_type)
limit = int(request.query_params.get('limit', 50)) limit = int(request.query_params.get('limit', 50))
return Response(AlertRecordSerializer(alerts[:limit], many=True).data) return Response(AlertRecordSerializer(alerts[:limit], many=True).data)
# ==================== Projects ====================
@api_view(['GET'])
def project_list_view(request):
"""从火山引擎拉取项目列表"""
account, ak, sk = _get_volc_account()
if not ak:
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
status=status.HTTP_400_BAD_REQUEST)
try:
svc = ProjectService(ak, sk)
projects = svc.list_projects()
result = [
{
'name': p.get('ProjectName', ''),
'display_name': p.get('DisplayName', p.get('ProjectName', '')),
'description': p.get('Description', ''),
}
for p in projects
]
return Response(result)
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)

View File

@ -5,8 +5,13 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# --- Core --- # --- 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') DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = 'dev-insecure-key-for-local-development-only'
else:
raise ValueError("DJANGO_SECRET_KEY must be set in production (DEBUG=False)")
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',') ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',')
# --- Apps --- # --- Apps ---
@ -73,10 +78,13 @@ if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
} }
} }
else: else:
# Docker uses /app/data/ volume; local dev uses backend/db.sqlite3
db_dir = Path(os.environ.get('DB_DIR', BASE_DIR))
db_dir.mkdir(parents=True, exist_ok=True)
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3', 'NAME': db_dir / 'db.sqlite3',
} }
} }
@ -91,6 +99,7 @@ AUTH_PASSWORD_VALIDATORS = [
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
'apps.monitor.permissions.APIKeyAuthentication',
], ],
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.IsAuthenticated',
@ -130,10 +139,6 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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 --- # --- Encryption ---
AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '') AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '')
@ -143,5 +148,3 @@ FEISHU_WEBHOOK_URL = os.environ.get('FEISHU_WEBHOOK_URL', '')
# --- API Key (for external systems like AirDrama) --- # --- API Key (for external systems like AirDrama) ---
AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '') AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '')
# --- Monitor ---
MONITOR_INTERVAL = int(os.environ.get('MONITOR_INTERVAL', '3600'))

19
backend/entrypoint.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "Running migrations..."
python manage.py migrate --noinput
echo "Creating default admin user..."
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', 'admin@airgate.local', 'admin123')
print('Default admin created: admin / admin123')
else:
print('Admin user already exists')
"
echo "Starting server..."
exec "$@"

View File

@ -1,7 +1,7 @@
"""IAM 子账号管理服务""" """IAM 子账号管理服务"""
import logging import logging
from .volcengine_client import get_iam_client, VolcengineAPIError from .volcengine_client import get_iam_client, get_resource_client, VolcengineAPIError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -118,3 +118,29 @@ class IAMService:
if errors: if errors:
raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors)) raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors))
class ProjectService:
"""封装火山引擎项目管理 API"""
def __init__(self, ak: str, sk: str):
self.client = get_resource_client(ak, sk)
def list_projects(self) -> list:
"""获取所有项目列表"""
projects = []
page = 1
while True:
resp = self.client.call("ListProjects", {
"PageNumber": str(page),
"PageSize": "50",
})
items = resp.get("Result", {}).get("Projects", [])
if not items:
break
projects.extend(items)
total = resp.get("Result", {}).get("Total", 0)
if len(projects) >= total:
break
page += 1
return projects

View File

@ -1,8 +1,8 @@
"""定时消费监控任务""" """定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
import logging import logging
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -10,15 +10,15 @@ _scheduler_started = False
def check_spending(): def check_spending():
"""定时检查所有子账号消费""" """定时检查所有子账号消费,对比已划拨额度触发阶梯告警"""
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
from utils.crypto import decrypt from utils.crypto import decrypt
from utils.billing_service import BillingService from utils.billing_service import BillingService
from utils.iam_service import IAMService from utils.iam_service import IAMService
from utils.feishu import send_feishu_alert from utils.feishu import send_feishu_alert
bill_period = datetime.now().strftime("%Y-%m")
config = GlobalConfig.get_solo() config = GlobalConfig.get_solo()
webhook = config.feishu_webhook_url
for volc_account in VolcAccount.objects.filter(is_active=True): for volc_account in VolcAccount.objects.filter(is_active=True):
ak = decrypt(volc_account.access_key_enc) ak = decrypt(volc_account.access_key_enc)
@ -37,87 +37,105 @@ def check_spending():
for user in users: for user in users:
try: try:
# 查询当月消费(按项目筛选)
bill_period = timezone.now().strftime("%Y-%m")
spending = billing.get_spending_by_project( spending = billing.get_spending_by_project(
bill_period, user.project_name or None 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() SpendingRecord.objects.update_or_create(
iam_user=user, bill_period=bill_period,
defaults={'amount': spending},
)
# Check disable threshold # 累计消费 = 所有月份的消费之和
if (user.auto_disable_enabled from django.db.models import Sum
and disable_threshold total = SpendingRecord.objects.filter(
and spending >= disable_threshold): iam_user=user
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
already_disabled = AlertRecord.objects.filter( user.consumed_total = total
iam_user=user, user.spending_updated_at = timezone.now()
alert_type=AlertRecord.AlertType.DISABLE,
created_at__month=datetime.now().month,
created_at__year=datetime.now().year,
).exists()
if not already_disabled: quota = user.allocated_quota
try: if not quota or quota <= 0:
iam_svc.disable_user(user.username) user.save(update_fields=['consumed_total', 'spending_updated_at'])
user.status = IAMUser.Status.DISABLED continue
user.save(update_fields=['status'])
except Exception as e:
logger.error(f"停用用户 {user.username} 失败: {e}")
alert = AlertRecord.objects.create( usage_percent = float(total) / float(quota) * 100
iam_user=user, triggered = user.triggered_alerts or []
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: for step in user.get_alert_thresholds():
already_alerted = AlertRecord.objects.filter( if usage_percent >= step and step not in triggered:
iam_user=user, triggered.append(step)
alert_type=AlertRecord.AlertType.WARNING, threshold_amount = Decimal(str(quota)) * step / 100
created_at__month=datetime.now().month,
created_at__year=datetime.now().year,
).exists()
if not already_alerted: AlertRecord.objects.create(
alert = AlertRecord.objects.create(
iam_user=user, iam_user=user,
alert_type=AlertRecord.AlertType.WARNING, alert_type=AlertRecord.AlertType.WARNING,
title=f"子账号 {user.username} 消费告警", title=f"{user.username} 消费达到额度 {step}%",
content=f"本月消费 ¥{spending:.2f},达到告警阈值 ¥{alert_threshold:.2f}", content=(
spending_amount=spending, f"累计消费 ¥{total:.2f}"
threshold_amount=alert_threshold, f"已划拨额度 ¥{quota:.2f}{step}%\n"
f"剩余额度: ¥{user.remaining_quota:.2f}"
),
spending_amount=total,
threshold_amount=threshold_amount,
notified=True,
) )
webhook = config.feishu_webhook_url
send_feishu_alert( send_feishu_alert(
webhook, webhook,
"⚠️ 子账号消费告警", f"⚠️ {user.username} 消费达到额度 {step}%",
f"**用户**: {user.username}\n" f"**用户**: {user.username}\n"
f"**消费**: ¥{spending:.2f}\n" f"**累计消费**: ¥{total:.2f}\n"
f"**告警阈值**: ¥{alert_threshold:.2f}\n" f"**已划拨额度**: ¥{quota:.2f}\n"
f"**停用阈值**: ¥{disable_threshold:.2f}", f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
template="orange", f"**使用率**: {usage_percent:.1f}%",
template="orange" if step < 90 else "red",
) )
alert.notified = True
alert.save(update_fields=['notified']) # --- 额度用尽,自动停用 ---
if (usage_percent >= 100
and user.auto_disable_enabled
and 100 not in triggered):
triggered.append(100)
try:
iam_svc.disable_user(user.username)
user.status = IAMUser.Status.DISABLED
except Exception as e:
logger.error(f"停用用户 {user.username} 失败: {e}")
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.DISABLE,
title=f"{user.username} 额度用尽,已自动停用",
content=(
f"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
f"如需继续使用,请划拨新额度后恢复账号。"
),
spending_amount=total,
threshold_amount=quota,
notified=True,
)
send_feishu_alert(
webhook,
f"🚨 {user.username} 额度用尽,已自动停用",
f"**用户**: {user.username}\n"
f"**累计消费**: ¥{total:.2f}\n"
f"**已划拨额度**: ¥{quota:.2f}\n"
f"额度已用尽,账号已自动停用。\n"
f"请在 AirGate 划拨新额度后恢复。",
template="red",
)
user.triggered_alerts = triggered
user.save(update_fields=[
'consumed_total', 'spending_updated_at',
'triggered_alerts', 'status',
])
except Exception as e: except Exception as e:
logger.error(f"检查用户 {user.username} 消费失败: {e}") logger.error(f"检查用户 {user.username} 消费失败: {e}")
@ -132,10 +150,11 @@ def start_scheduler():
try: try:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from django.conf import settings from apps.monitor.models import GlobalConfig
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
interval = getattr(settings, 'MONITOR_INTERVAL', 3600) config = GlobalConfig.get_solo()
interval = config.monitor_interval_seconds or 3600
scheduler.add_job(check_spending, 'interval', seconds=interval, scheduler.add_job(check_spending, 'interval', seconds=interval,
id='check_spending', replace_existing=True) id='check_spending', replace_existing=True)
scheduler.start() scheduler.start()

View File

@ -113,3 +113,8 @@ def get_iam_client(ak: str, sk: str) -> VolcengineClient:
def get_billing_client(ak: str, sk: str) -> VolcengineClient: def get_billing_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com", return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
version="2022-01-01") version="2022-01-01")
def get_resource_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
version="2021-08-01")

27
docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8101:8100"
env_file:
- .env
environment:
- DJANGO_ALLOWED_HOSTS=*
- CORS_ALLOWED_ORIGINS=http://localhost:5174,http://localhost
- DB_DIR=/app/data
volumes:
- backend-data:/app/data
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "5174:80"
depends_on:
- backend
restart: unless-stopped
volumes:
backend-data:

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/nginx.conf Normal file
View File

@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -3,7 +3,7 @@ import { useAuthStore } from '../stores/auth'
import router from '../router' import router from '../router'
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8100', baseURL: import.meta.env.VITE_API_BASE || '',
timeout: 30000, timeout: 30000,
}) })

View File

@ -13,7 +13,7 @@
<el-row :gutter="20" style="margin-bottom: 20px;" v-if="balance"> <el-row :gutter="20" style="margin-bottom: 20px;" v-if="balance">
<el-col :span="8"> <el-col :span="8">
<el-card shadow="hover"> <el-card shadow="hover">
<el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || 0)" <el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || balance.availableBalance || 0)"
:precision="2" prefix="¥" /> :precision="2" prefix="¥" />
</el-card> </el-card>
</el-col> </el-col>
@ -21,33 +21,48 @@
<el-card> <el-card>
<template #header> <template #header>
<span>各子账号本月消费</span> <span>各子账号消费与额度</span>
</template> </template>
<el-table :data="overview.users || []" stripe v-loading="loading" style="width:100%;" <el-table :data="overview.users || []" stripe v-loading="loading" style="width:100%;"
:default-sort="{ prop: 'current_month_spending', order: 'descending' }"> :default-sort="{ prop: 'consumed_total', order: 'descending' }">
<el-table-column prop="username" label="用户名" width="160" /> <el-table-column prop="username" label="用户名" width="160" />
<el-table-column prop="display_name" label="显示名" width="140" /> <el-table-column prop="display_name" label="显示名" width="140" />
<el-table-column prop="project_name" label="项目" width="160" /> <el-table-column prop="project_name" label="项目" width="160" />
<el-table-column prop="current_month_spending" label="本月消费" width="140" sortable> <el-table-column prop="consumed_total" label="累计消费" width="140" sortable>
<template #default="{ row }"> <template #default="{ row }">
<span style="font-weight: 600; color: #e6a23c;"> <span style="font-weight: 600; color: #e6a23c;">
¥{{ Number(row.current_month_spending).toFixed(2) }} ¥{{ Number(row.consumed_total).toLocaleString() }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警阈值" width="120"> <el-table-column label="已划拨额度" width="120">
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template> <template #default="{ row }">¥{{ Number(row.allocated_quota).toLocaleString() }}</template>
</el-table-column> </el-table-column>
<el-table-column label="停用阈值" width="120"> <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 }"> <template #default="{ row }">
<el-progress <span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
:percentage="Math.min(100, (Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100))" ¥{{ Number(row.remaining_quota).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column label="额度使用率" min-width="200">
<template #default="{ row }">
<el-progress v-if="Number(row.allocated_quota) > 0"
:percentage="Math.min(100, row.usage_percent || 0)"
:color="progressColor(row)" :color="progressColor(row)"
:stroke-width="12" :stroke-width="12"
:format="() => `${row.usage_percent || 0}%`"
/> />
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="告警阶梯" width="150">
<template #default="{ row }">
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
size="small" style="margin:1px 2px;">
{{ step }}%
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="spending_updated_at" label="更新时间" width="180"> <el-table-column prop="spending_updated_at" label="更新时间" width="180">
@ -105,8 +120,8 @@ async function loadBalance() {
} }
function progressColor(row) { function progressColor(row) {
const pct = Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100 const pct = row.usage_percent || 0
if (pct >= 80) return '#f56c6c' if (pct >= 90) return '#f56c6c'
if (pct >= 50) return '#e6a23c' if (pct >= 50) return '#e6a23c'
return '#67c23a' return '#67c23a'
} }

View File

@ -23,7 +23,7 @@
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-card shadow="hover"> <el-card shadow="hover">
<el-statistic title="本月总消费" :value="Number(data.total_spending)" :precision="2" <el-statistic title="累计总消费" :value="Number(data.total_spending)" :precision="2"
prefix="¥" /> prefix="¥" />
</el-card> </el-card>
</el-col> </el-col>

View File

@ -2,72 +2,150 @@
<div> <div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>子账号管理</h2> <h2>子账号管理</h2>
<div> <div style="display:flex; gap:8px;">
<el-button type="success" @click="showCreate = true">
<el-icon><Plus /></el-icon> 创建子账号
</el-button>
<el-button type="primary" @click="handleSync" :loading="syncing"> <el-button type="primary" @click="handleSync" :loading="syncing">
<el-icon><Refresh /></el-icon> 同步火山用户 <el-icon><Refresh /></el-icon> 同步已有用户
</el-button> </el-button>
</div> </div>
</div> </div>
<el-card> <el-card>
<el-table :data="users" stripe v-loading="loading" style="width: 100%;"> <el-table :data="users" stripe v-loading="loading" style="width: 100%;">
<el-table-column prop="username" label="用户名" width="160" /> <el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="display_name" label="显示名" width="140" /> <el-table-column prop="display_name" label="显示名" width="110" />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small"> <el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }} {{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="project_name" label="关联项目" width="140" /> <el-table-column label="已划拨额度" width="120">
<el-table-column label="本月消费" width="120"> <template #default="{ row }">¥{{ Number(row.allocated_quota).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="累计消费" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: Number(row.current_month_spending) > 0 ? '#e6a23c' : '' }"> <span :style="{ color: Number(row.consumed_total) > 0 ? '#e6a23c' : '' }">
¥{{ Number(row.current_month_spending).toFixed(2) }} ¥{{ Number(row.consumed_total).toLocaleString() }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警阈值" width="110"> <el-table-column label="剩余额度" width="120">
<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 }"> <template #default="{ row }">
<el-tag :type="row.monitor_enabled ? 'success' : 'info'" size="small"> <span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
{{ row.monitor_enabled ? '开' : '关' }} ¥{{ Number(row.remaining_quota).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column label="使用率" width="140">
<template #default="{ row }">
<el-progress v-if="Number(row.allocated_quota) > 0"
:percentage="Math.min(100, row.usage_percent || 0)"
:color="row.usage_percent >= 90 ? '#f56c6c' : row.usage_percent >= 50 ? '#e6a23c' : '#67c23a'"
:stroke-width="10"
:format="() => `${row.usage_percent || 0}%`"
/>
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="告警阶梯" width="130">
<template #default="{ row }">
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
size="small" style="margin:1px 2px;">
{{ step }}%
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="260" fixed="right"> <el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="warning" @click="openAllocate(row)">划拨</el-button>
<el-button size="small" @click="openConfig(row)">配置</el-button> <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 === '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 v-if="row.status === 'disabled'" size="small" type="success" @click="handleEnable(row)">恢复</el-button>
<el-button size="small" @click="viewPolicies(row)">权限</el-button> <el-button size="small" @click="openPolicies(row)">权限</el-button>
<el-button size="small" @click="openQuotaHistory(row)">记录</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<!-- Allocate Dialog -->
<el-dialog v-model="allocateVisible" title="额度变更" width="480px">
<div style="margin-bottom:16px; padding:12px; background:#f5f7fa; border-radius:8px;">
<div>用户: <b>{{ allocateUser?.username }}</b></div>
<div>当前额度: ¥{{ Number(allocateUser?.allocated_quota || 0).toLocaleString() }}</div>
<div>已消费: ¥{{ Number(allocateUser?.consumed_total || 0).toLocaleString() }}</div>
<div>剩余: <span style="color:#67c23a;font-weight:600;">¥{{ Number(allocateUser?.remaining_quota || 0).toLocaleString() }}</span></div>
</div>
<el-form :model="allocateForm" label-width="100px">
<el-form-item label="操作类型">
<el-radio-group v-model="allocateForm.mode">
<el-radio-button value="add">追加额度</el-radio-button>
<el-radio-button value="deduct">扣减额度</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="allocateForm.mode === 'add' ? '追加金额(元)' : '扣减金额(元)'">
<el-input-number v-model="allocateForm.amount" :min="1" :step="10000"
:max="allocateForm.mode === 'deduct' ? maxDeduct : undefined"
:precision="2" style="width:100%;" controls-position="right" />
<div v-if="allocateForm.mode === 'deduct'" class="form-hint">
最多可扣减: ¥{{ maxDeduct.toLocaleString() }}不能低于已消费金额
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="allocateForm.note" :placeholder="allocateForm.mode === 'add' ? '如3月额度追加' : '如:额度调整'" />
</el-form-item>
</el-form>
<div v-if="allocateForm.amount" style="padding:8px 12px; border-radius:4px; font-size:13px;"
:style="{ background: allocateForm.mode === 'add' ? '#f0f9eb' : '#fef0f0' }">
变更后总额度: ¥{{ newTotalAfter.toLocaleString() }}
</div>
<template #footer>
<el-button @click="allocateVisible = false">取消</el-button>
<el-button :type="allocateForm.mode === 'add' ? 'primary' : 'warning'"
@click="submitAllocate" :loading="allocating">
{{ allocateForm.mode === 'add' ? '确认追加' : '确认扣减' }}
</el-button>
</template>
</el-dialog>
<!-- Config Dialog --> <!-- Config Dialog -->
<el-dialog v-model="configVisible" title="阈值配置" width="500px"> <el-dialog v-model="configVisible" title="监控配置" width="560px">
<el-form :model="configForm" label-width="120px"> <el-form :model="configForm" label-width="130px">
<el-form-item label="告警阈值(元)"> <el-form-item label="关联项目">
<el-input-number v-model="configForm.alert_threshold" :min="0" :precision="2" style="width:100%;" /> <el-select v-model="configForm.project_name" placeholder="选择火山引擎项目"
<div class="form-hint">留空则使用全局默认值</div> filterable clearable style="width:100%;" :loading="projectsLoading">
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
</el-select>
<div class="form-hint">
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="停用阈值(元)">
<el-input-number v-model="configForm.disable_threshold" :min="0" :precision="2" style="width:100%;" /> <el-divider content-position="left">告警阶梯</el-divider>
<div class="form-hint">留空则使用全局默认值</div>
<el-form-item label="告警阶梯(%)">
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<el-tag v-for="(step, i) in configForm.alert_thresholds" :key="i"
closable @close="removeStep(i)" size="large">{{ step }}%</el-tag>
<el-input-number v-model="newStep" :min="1" :max="99" size="small" style="width:100px;" />
<el-button size="small" @click="addStep">添加</el-button>
</div>
<div class="form-hint">达到已划拨额度对应百分比时发送告警</div>
</el-form-item> </el-form-item>
<el-divider content-position="left">开关</el-divider>
<el-form-item label="消费监控"> <el-form-item label="消费监控">
<el-switch v-model="configForm.monitor_enabled" /> <el-switch v-model="configForm.monitor_enabled" />
</el-form-item> </el-form-item>
<el-form-item label="自动停用"> <el-form-item label="额度用尽自动停用">
<el-switch v-model="configForm.auto_disable_enabled" /> <el-switch v-model="configForm.auto_disable_enabled" />
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -76,19 +154,109 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Policies Dialog --> <!-- Quota History Dialog -->
<el-dialog v-model="policiesVisible" title="权限策略" width="600px"> <el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
<el-table :data="policies" stripe v-loading="policiesLoading"> <el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
<el-table-column prop="PolicyName" label="策略名" /> <el-table-column prop="created_at" label="时间" width="180">
<el-table-column prop="PolicyType" label="类型" width="100" /> <template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
<el-table-column prop="Description" label="说明" /> </el-table-column>
<el-table-column prop="amount" label="变更金额" width="120">
<template #default="{ row }">
<span :style="{ color: Number(row.amount) >= 0 ? '#67c23a' : '#f56c6c' }">
{{ Number(row.amount) >= 0 ? '+' : '' }}¥{{ Number(row.amount).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column prop="total_after" label="划拨后总额度" width="130">
<template #default="{ row }">¥{{ Number(row.total_after).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" />
<el-table-column prop="created_by" label="操作人" width="100" />
</el-table> </el-table>
</el-dialog> </el-dialog>
<!-- Policies Dialog -->
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="650px">
<div style="margin-bottom:12px; display:flex; gap:8px;">
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
<el-option-group label="常用策略">
<el-option value="ArkFullAccess" label="ArkFullAccess方舟/Seedance 完整权限)" />
<el-option value="ArkReadOnlyAccess" label="ArkReadOnlyAccess方舟只读" />
<el-option value="TOSFullAccess" label="TOSFullAccess对象存储完整权限" />
<el-option value="TOSReadOnlyAccess" label="TOSReadOnlyAccess对象存储只读" />
<el-option value="AccessKeySelfManageAccess" label="AccessKeySelfManageAccess自管理密钥" />
</el-option-group>
</el-select>
<el-button type="primary" @click="handleAttachPolicy" :disabled="!policyToAttach">附加</el-button>
</div>
<el-table :data="policies" stripe v-loading="policiesLoading" empty-text="暂无策略">
<el-table-column prop="PolicyName" label="策略名" />
<el-table-column prop="PolicyType" label="类型" width="80" />
<el-table-column prop="Description" label="说明" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleDetachPolicy(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- Create User Dialog -->
<el-dialog v-model="showCreate" title="创建子账号" width="520px">
<el-alert type="warning" :closable="false" style="margin-bottom:16px;"
description="创建后会在火山引擎生成 IAM 用户和 API 密钥。SecretKey 仅显示一次,请务必保存!" />
<el-form :model="createForm" label-width="110px">
<el-form-item label="用户名" required>
<el-input v-model="createForm.username" placeholder="英文字母开头,如 dept_video" />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="createForm.display_name" placeholder="如:视频部门" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="createForm.email" placeholder="选填" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="createForm.phone" placeholder="选填,如 +8618000000000" />
</el-form-item>
<el-form-item label="控制台密码">
<el-input v-model="createForm.password" type="password" show-password
placeholder="选填,填了才开通控制台登录" />
</el-form-item>
<el-form-item label="关联项目">
<el-select v-model="createForm.project_name" placeholder="选填" filterable clearable
style="width:100%;" :loading="projectsLoading">
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="creating">创建</el-button>
</template>
</el-dialog>
<!-- Secret Key Display Dialog -->
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="520px" :close-on-click-modal="false">
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
description="SecretAccessKey 仅此一次显示!关闭后无法再次获取,请立即复制保存。" />
<el-descriptions :column="1" border>
<el-descriptions-item label="AccessKey ID">
<code>{{ createdKeys.access_key_id }}</code>
</el-descriptions-item>
<el-descriptions-item label="SecretAccessKey">
<code style="word-break:break-all;">{{ createdKeys.secret_access_key }}</code>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="copyKeys">复制到剪贴板</el-button>
<el-button @click="showSecretKey = false">已保存关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../../api' import api from '../../api'
@ -96,14 +264,35 @@ const users = ref([])
const loading = ref(false) const loading = ref(false)
const syncing = ref(false) const syncing = ref(false)
// Create
const showCreate = ref(false)
const creating = ref(false)
const createForm = ref({ username: '', display_name: '', email: '', phone: '', password: '', project_name: '' })
const showSecretKey = ref(false)
const createdKeys = ref({ access_key_id: '', secret_access_key: '' })
// Allocate
const allocateVisible = ref(false)
const allocateUser = ref(null)
const allocateForm = ref({ amount: null, note: '' })
const allocating = ref(false)
// Config
const configVisible = ref(false) const configVisible = ref(false)
const configForm = ref({}) const configForm = ref({})
const configUserId = ref(null) const configUserId = ref(null)
const saving = ref(false) const saving = ref(false)
const newStep = ref(null)
const policiesVisible = ref(false) // Projects
const policies = ref([]) const projects = ref([])
const policiesLoading = ref(false) const projectsLoading = ref(false)
// Quota History
const historyVisible = ref(false)
const historyUser = ref(null)
const quotaHistory = ref([])
const historyLoading = ref(false)
async function loadUsers() { async function loadUsers() {
loading.value = true loading.value = true
@ -131,7 +320,9 @@ async function handleSync() {
} }
async function handleDisable(row) { async function handleDisable(row) {
await ElMessageBox.confirm(`确定要停用子账号 "${row.username}" 吗?停用后该账号的控制台和 API 访问都将被禁止。`, '确认停用', { type: 'warning' }) await ElMessageBox.confirm(
`确定要停用子账号 "${row.username}" 吗?`, '确认停用', { type: 'warning' }
)
try { try {
await api.post(`/api/v1/iam-users/${row.id}/disable/`) await api.post(`/api/v1/iam-users/${row.id}/disable/`)
ElMessage.success('已停用') ElMessage.success('已停用')
@ -152,15 +343,97 @@ async function handleEnable(row) {
} }
} }
// Policies
const policiesVisible = ref(false)
const policiesUser = ref(null)
const policies = ref([])
const policiesLoading = ref(false)
const policyToAttach = ref('')
// --- Allocate ---
const maxDeduct = computed(() => {
if (!allocateUser.value) return 0
return Math.max(0, Number(allocateUser.value.allocated_quota || 0) - Number(allocateUser.value.consumed_total || 0))
})
const newTotalAfter = computed(() => {
const current = Number(allocateUser.value?.allocated_quota || 0)
const amt = Number(allocateForm.value.amount || 0)
return allocateForm.value.mode === 'add' ? current + amt : current - amt
})
function openAllocate(row) {
allocateUser.value = row
allocateForm.value = { mode: 'add', amount: null, note: '' }
allocateVisible.value = true
}
async function submitAllocate() {
if (!allocateForm.value.amount || allocateForm.value.amount <= 0) {
ElMessage.warning('请输入金额')
return
}
allocating.value = true
const actualAmount = allocateForm.value.mode === 'deduct'
? -allocateForm.value.amount
: allocateForm.value.amount
try {
const { data } = await api.post(
`/api/v1/iam-users/${allocateUser.value.id}/allocate/`,
{ amount: actualAmount, note: allocateForm.value.note }
)
ElMessage.success(data.message)
allocateVisible.value = false
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '操作失败')
} finally {
allocating.value = false
}
}
// --- Config ---
async function loadProjects() {
projectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
projects.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取项目列表失败')
} finally {
projectsLoading.value = false
}
}
function openConfig(row) { function openConfig(row) {
configUserId.value = row.id configUserId.value = row.id
configForm.value = { configForm.value = {
alert_threshold: row.alert_threshold ? Number(row.alert_threshold) : null, project_name: row.project_name || '',
disable_threshold: row.disable_threshold ? Number(row.disable_threshold) : null, alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
monitor_enabled: row.monitor_enabled, monitor_enabled: row.monitor_enabled,
auto_disable_enabled: row.auto_disable_enabled, auto_disable_enabled: row.auto_disable_enabled,
} }
newStep.value = null
configVisible.value = true configVisible.value = true
if (projects.value.length === 0) loadProjects()
}
function addStep() {
if (!newStep.value || newStep.value < 1 || newStep.value > 99) {
ElMessage.warning('请输入 1-99 之间的百分比')
return
}
if (configForm.value.alert_thresholds.includes(newStep.value)) {
ElMessage.warning('该阈值已存在')
return
}
configForm.value.alert_thresholds.push(newStep.value)
configForm.value.alert_thresholds.sort((a, b) => a - b)
newStep.value = null
}
function removeStep(index) {
configForm.value.alert_thresholds.splice(index, 1)
} }
async function saveConfig() { async function saveConfig() {
@ -177,9 +450,28 @@ async function saveConfig() {
} }
} }
async function viewPolicies(row) { // --- Quota History ---
async function openQuotaHistory(row) {
historyUser.value = row
historyVisible.value = true
historyLoading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${row.id}/quota-history/`)
quotaHistory.value = data
} catch (e) {
ElMessage.error('获取划拨记录失败')
quotaHistory.value = []
} finally {
historyLoading.value = false
}
}
// --- Policies ---
async function openPolicies(row) {
policiesUser.value = row
policiesVisible.value = true policiesVisible.value = true
policiesLoading.value = true policiesLoading.value = true
policyToAttach.value = ''
try { try {
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`) const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
policies.value = data.policies || [] policies.value = data.policies || []
@ -191,9 +483,78 @@ async function viewPolicies(row) {
} }
} }
async function handleAttachPolicy() {
if (!policyToAttach.value) return
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/attach/`, {
policy_name: policyToAttach.value,
policy_type: 'System',
})
ElMessage.success(`已附加 ${policyToAttach.value}`)
policyToAttach.value = ''
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '附加失败')
}
}
async function handleDetachPolicy(row) {
await ElMessageBox.confirm(`确定移除策略 "${row.PolicyName}" 吗?`, '确认移除', { type: 'warning' })
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/detach/`, {
policy_name: row.PolicyName,
policy_type: row.PolicyType,
})
ElMessage.success(`已移除 ${row.PolicyName}`)
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
// --- Create User ---
async function handleCreate() {
if (!createForm.value.username) {
ElMessage.warning('请输入用户名')
return
}
creating.value = true
try {
const { data } = await api.post('/api/v1/iam-users/create/', createForm.value)
ElMessage.success(data.message)
showCreate.value = false
createForm.value = { username: '', display_name: '', email: '', phone: '', password: '', project_name: '' }
// Show secret key if generated
if (data.volcengine?.secret_access_key) {
createdKeys.value = {
access_key_id: data.volcengine.access_key_id,
secret_access_key: data.volcengine.secret_access_key,
}
showSecretKey.value = true
}
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '创建失败')
} finally {
creating.value = false
}
}
async function copyKeys() {
const text = `AccessKey ID: ${createdKeys.value.access_key_id}\nSecretAccessKey: ${createdKeys.value.secret_access_key}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
onMounted(loadUsers) onMounted(loadUsers)
</script> </script>
<style scoped> <style scoped>
.form-hint { font-size: 12px; color: #999; margin-top: 4px; } .form-hint { font-size: 12px; color: #999; margin-top: 4px; }
.switch-hint { font-size: 12px; color: #999; margin-left: 8px; }
</style> </style>

View File

@ -6,11 +6,11 @@
<el-card style="margin-bottom: 20px;"> <el-card style="margin-bottom: 20px;">
<template #header><span>全局默认配置</span></template> <template #header><span>全局默认配置</span></template>
<el-form :model="config" label-width="180px" v-loading="loadingConfig"> <el-form :model="config" label-width="180px" v-loading="loadingConfig">
<el-form-item label="默认告警阈值(元)"> <el-form-item label="默认告警阶梯(%)">
<el-input-number v-model="config.default_alert_threshold" :min="0" :precision="2" /> <el-input v-model="alertThresholdsStr" placeholder="50,80,90" />
</el-form-item> <div style="font-size:12px;color:#999;margin-top:4px;">
<el-form-item label="默认停用阈值(元)"> 逗号分隔的百分比 50,80,90 表示消费达到已划拨额度的 50%/80%/90%
<el-input-number v-model="config.default_disable_threshold" :min="0" :precision="2" /> </div>
</el-form-item> </el-form-item>
<el-form-item label="监控间隔(秒)"> <el-form-item label="监控间隔(秒)">
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" /> <el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
@ -77,7 +77,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../../api' import api from '../../api'
@ -86,6 +86,17 @@ const config = ref({})
const loadingConfig = ref(false) const loadingConfig = ref(false)
const savingConfig = ref(false) const savingConfig = ref(false)
const alertThresholdsStr = computed({
get: () => (config.value.default_alert_thresholds || []).join(','),
set: (val) => {
config.value.default_alert_thresholds = val
.split(',')
.map(s => parseInt(s.trim()))
.filter(n => n >= 1 && n <= 99)
.sort((a, b) => a - b)
},
})
// Volc accounts // Volc accounts
const accounts = ref([]) const accounts = ref([])
const loadingAccounts = ref(false) const loadingAccounts = ref(false)

View File

@ -4,10 +4,10 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173, port: 5174,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8100', target: 'http://localhost:8101',
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@ -31,10 +31,10 @@
|------|----------|--------| |------|----------|--------|
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** | | 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** | | 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
| 子账号能看到自己的账单 | 通过项目 + 标签维度,主账号代查并展示 | **部分可行**(见下方说明)| | 子账号能看到自己的账单 | 通过 AirGate 按项目维度查询,主账号代查展示 | **部分可行**(见下方说明)|
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** | | 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
| 消费达到阈值发告警 | 定时轮询 Billing API + Webhook/飞书通知 | **完全可行** | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警50%/80%/90%+ 飞书通知 | **完全可行** |
| 消费达到阈值自动停用 | 轮询消费 + 调用 IAM API 停用用户和密钥 | **完全可行** | | 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** | | 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** |
### 1.2 架构图 ### 1.2 架构图
@ -594,21 +594,34 @@ balance = billing_client.call("QueryBalanceAcct")
- 仅支持在线推理(不含批量) - 仅支持在线推理(不含批量)
- 目前仅支持通过控制台设置,**暂无公开 API** - 目前仅支持通过控制台设置,**暂无公开 API**
### 7.4 自建告警方案(推荐) ### 7.4 AirGate 自建方案:额度划拨制 + 阶梯式告警
由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。需要自建: 由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。AirGate 采用**额度划拨制**自建:
``` ```
┌──────────────────────────────────────────────┐ 主账号通过 AirGate 给子账号划拨额度(如 10 万元)
│ 定时任务 (每小时/每天) │
│ │ ▼ 定时任务每小时查询 Billing API
│ 1. 调用 ListBillDetail 查询各子账号消费 │ 累计消费不断增长,对比已划拨额度
│ 2. 与预设阈值对比 │
│ 3. 达到告警阈值 → 发送通知 │ ├── 消费达到额度 50% → 飞书告警
│ 4. 达到停用阈值 → 调用 IAM API 停用用户 │ ├── 消费达到额度 80% → 飞书告警
└──────────────────────────────────────────────┘ ├── 消费达到额度 90% → 飞书告警
└── 消费达到额度 100% → 自动停用子账号 + 飞书告警
额度用完 → 主账号在 AirGate 追加额度 → 告警状态自动重置 → 恢复子账号
额度给多了 → 主账号在 AirGate 扣减额度 → 告警状态自动重置
``` ```
**关键设计:**
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
- **可追加可扣减**:主账号可随时追加额度(+5万或扣减额度-3万支持灵活调整
- **扣减保护**:扣减后总额度不能低于已消费金额(否则会立即触发停用)
- **阶梯式告警**:每个子账号可自定义告警百分比(如 [50, 80, 90]),每档只通知一次
- **额度变更即重置告警**:追加或扣减额度后,已触发的告警状态自动清空,按新的使用率重新计算
- **累计消费**:跨月累计,通过 Billing API 各月数据求和得出
- **操作留痕**:每次划拨/扣减都记录操作人、金额、备注,可追溯
--- ---
## 8. 子账号自动停用/恢复方案 ## 8. 子账号自动停用/恢复方案
@ -915,10 +928,9 @@ iam.call("AttachUserPolicy", {
#### Step 4创建项目并分配 #### Step 4创建项目并分配
```python ```python
# 使用项目管理 API通过 open.volcengineapi.com # 项目管理 API与 IAM 共用端点,但 Version 不同)
# 注意:项目管理的 service 签名名称需要根据实际情况确认, project_client = VolcengineClient(AK, SK, "iam", "iam.volcengineapi.com",
# 可能是 "resource_manager" 或其他名称,建议先用 API Explorer 测试 version="2021-08-01")
project_client = VolcengineClient(AK, SK, "resource_manager", "open.volcengineapi.com")
# 创建项目 # 创建项目
project_client.call("CreateProject", { project_client.call("CreateProject", {
@ -982,153 +994,65 @@ def get_user_spending(bill_period: str, project_name: str = None) -> float:
return total return total
``` ```
### 第三阶段:告警与自动停用 ### 第三阶段:告警与自动停用(已在 AirGate 中实现)
#### Step 6消费监控与告警服务 > 以下逻辑已通过 AirGate 管理平台实现,不再需要手写脚本。
> 详见 `backend/utils/scheduler.py``backend/apps/monitor/views.py`
```python **AirGate 实现的核心流程:**
import time
import logging
import requests as http_requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") 1. 主账号通过界面给子账号**划拨额度**(如 10 万元)
logger = logging.getLogger("volcengine_monitor") 2. 定时任务每小时调用 Billing API 查询累计消费
3. 消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
4. 消费达到 100% → 自动停用子账号 + 飞书告警
5. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
# 配置 **告警状态管理:**
ALERT_THRESHOLD = 1000.0 # 告警阈值(元) - 每个阶梯只通知一次,通过 `triggered_alerts` 字段(存数据库)去重
DISABLE_THRESHOLD = 5000.0 # 停用阈值(元) - 追加额度时自动重置 `triggered_alerts`,按新使用率重新计算
CHECK_INTERVAL = 3600 # 检查间隔(秒),每小时 - 不需要月度重置,因为是额度制而非月度制
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK_ID" #### Step 6AirGate 已实现的 API 接口
MANAGED_USERS = [
{
"username": "dept_a_user",
"project": "DeptA-Project",
"alert_threshold": ALERT_THRESHOLD,
"disable_threshold": DISABLE_THRESHOLD,
}
]
# 记录已停用的用户,避免重复停用和重复告警
_disabled_users: set = set()
# 记录已发送告警的用户,避免每轮都重复告警
_alerted_users: set = set()
def send_feishu_alert(title: str, content: str):
"""发送飞书告警"""
payload = {
"msg_type": "interactive",
"card": {
"header": {"title": {"tag": "plain_text", "content": title}},
"elements": [
{"tag": "div", "text": {"tag": "plain_text", "content": content}}
]
}
}
try:
resp = http_requests.post(FEISHU_WEBHOOK, json=payload, timeout=10)
resp.raise_for_status()
except Exception as e:
logger.error(f"飞书告警发送失败: {e}")
def check_and_alert():
"""检查消费并触发告警/停用"""
from datetime import datetime
bill_period = datetime.now().strftime("%Y-%m")
for user in MANAGED_USERS:
username = user["username"]
# 已停用的用户跳过(避免重复停用和告警)
if username in _disabled_users:
logger.info(f"用户 {username} 已处于停用状态,跳过检查")
continue
try:
spending = get_user_spending(bill_period, user["project"])
except RuntimeError as e:
logger.error(f"查询用户 {username} 消费失败: {e}")
continue
logger.info(f"用户 {username} 本月消费: ¥{spending:.2f}")
if spending >= user["disable_threshold"]:
# 自动停用(自动查询该用户的 AccessKey
try:
disable_sub_user(iam, username)
_disabled_users.add(username)
send_feishu_alert(
"【紧急】子账号已自动停用",
f"用户 {username} 本月消费 ¥{spending:.2f}"
f"已达到停用阈值 ¥{user['disable_threshold']:.2f},已自动停用。\n"
f"如需恢复,请在管控工具中操作。"
)
except RuntimeError as e:
logger.error(f"停用用户 {username} 失败: {e}")
send_feishu_alert("【错误】自动停用失败",
f"用户 {username} 消费 ¥{spending:.2f},停用失败: {e}")
elif spending >= user["alert_threshold"] and username not in _alerted_users:
# 发送告警(每账期只告警一次)
_alerted_users.add(username)
send_feishu_alert(
"【警告】子账号消费告警",
f"用户 {username} 本月消费 ¥{spending:.2f}"
f"已达到告警阈值 ¥{user['alert_threshold']:.2f}。\n"
f"停用阈值:¥{user['disable_threshold']:.2f}"
)
# 主循环(生产环境建议用 cron 或 APScheduler
def main():
logger.info("消费监控服务启动")
while True:
try:
check_and_alert()
except Exception as e:
logger.error(f"监控服务异常: {e}")
send_feishu_alert("【错误】监控服务异常", str(e))
time.sleep(CHECK_INTERVAL)
```
> **注意**:每月初应重置 `_disabled_users``_alerted_users` 集合(或使用持久化存储),
> 否则跨月后状态不正确。生产环境建议将状态存入数据库或 Redis。
#### Step 7管理后台 API 接口设计
``` ```
# 用户管理 # 仪表盘
GET /api/iam/users # 列出所有子账号 GET /api/v1/dashboard/ # 总览(用户数/消费/告警)
POST /api/iam/users # 创建子账号
GET /api/iam/users/{username} # 查询子账号详情
PUT /api/iam/users/{username} # 更新子账号
DELETE /api/iam/users/{username} # 删除子账号
# 启停控制 # 火山主账号管理
POST /api/iam/users/{username}/disable # 停用子账号 GET /api/v1/volc-accounts/ # 列出主账号
POST /api/iam/users/{username}/enable # 恢复子账号 POST /api/v1/volc-accounts/ # 添加主账号AK/SK 加密存储)
PUT /api/v1/volc-accounts/{id}/ # 更新主账号
DELETE /api/v1/volc-accounts/{id}/ # 删除主账号
POST /api/v1/volc-accounts/{id}/test/ # 测试密钥有效性
# 权限管理 # IAM 子账号管理
GET /api/iam/users/{username}/policies # 查看子账号权限 GET /api/v1/iam-users/ # 列出所有子账号
POST /api/iam/users/{username}/policies # 附加权限 POST /api/v1/iam-users/sync/ # 从火山同步全部子账号
DELETE /api/iam/users/{username}/policies # 移除权限 POST /api/v1/iam-users/import/ # 导入指定子账号
GET /api/v1/iam-users/{id}/ # 查询子账号详情
PUT /api/v1/iam-users/{id}/update/ # 更新配置(告警阈值/开关)
POST /api/v1/iam-users/{id}/disable/ # 停用子账号
POST /api/v1/iam-users/{id}/enable/ # 恢复子账号
GET /api/v1/iam-users/{id}/policies/ # 查看权限策略
# 密钥管理 # 额度管理
GET /api/iam/users/{username}/access-keys # 列出密钥 POST /api/v1/iam-users/{id}/allocate/ # 追加额度(正数)或扣减额度(负数)
POST /api/iam/users/{username}/access-keys # 创建密钥 GET /api/v1/iam-users/{id}/quota-history/ # 查看额度变更记录(含追加和扣减)
PUT /api/iam/users/{username}/access-keys/{id} # 启停密钥
# 消费查询 # 消费查询
GET /api/billing/users/{username}/spending # 查询子账号消费 GET /api/v1/billing/overview/ # 消费总览
GET /api/billing/overview # 消费总览 POST /api/v1/billing/refresh/ # 手动刷新消费数据
GET /api/v1/billing/balance/ # 主账号余额
# 告警配置 # 全局配置
GET /api/alerts/config # 查看告警配置 GET /api/v1/config/ # 查看全局配置
PUT /api/alerts/config # 更新阈值配置 PUT /api/v1/config/ # 更新全局配置
GET /api/alerts/history # 告警历史
# 告警记录
GET /api/v1/alerts/ # 告警历史(支持类型筛选)
# 项目列表
GET /api/v1/projects/ # 从火山拉取项目列表
``` ```
--- ---
@ -1145,7 +1069,7 @@ GET /api/alerts/history # 告警历史
| SecretKey 仅返回一次 | 创建后立即保存 | | SecretKey 仅返回一次 | 创建后立即保存 |
| Billing API QPS 限制 5 | 批量查询需注意限流 | | Billing API QPS 限制 5 | 批量查询需注意限流 |
| Ark 推理限额无公开 API | 目前仅支持控制台操作 | | Ark 推理限额无公开 API | 目前仅支持控制台操作 |
| 预算告警仅通知不自动执行 | 需自建自动停用逻辑 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
### 12.2 安全建议 ### 12.2 安全建议
@ -1157,10 +1081,11 @@ GET /api/alerts/history # 告警历史
### 12.3 消费监控的精确度问题 ### 12.3 消费监控的精确度问题
由于账单数据有 1-2 天延迟,消费监控存在滞后。应对策略: 由于账单数据有 1-2 天延迟消费监控存在滞后。AirGate 的应对策略:
- 设置更保守的阈值(如实际想控制 5000 元,告警阈值设 3000停用阈值设 4000 - **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90]
- 结合 Ark 推理限额功能(自动暂停,无延迟) - **阶梯式告警**:在额度用尽前的多个节点提前告警,给管理员反应时间
- 高频轮询(如每小时)以尽早发现异常 - **高频轮询**:每小时查一次,虽然数据本身有延迟,但能在数据更新后第一时间触发告警
- 结合 Ark 推理限额功能(控制台手动设置,自动暂停,无延迟)作为兜底
--- ---
@ -1215,4 +1140,8 @@ GET /api/alerts/history # 告警历史
--- ---
> **下一步行动**:基于此报告,可以开始开发管控工具的后端服务。建议使用 Python + `volcengine-python-sdk`,先实现核心的 IAM 管理和消费监控功能,再逐步集成到 AirDrama 管理后台。 > **当前进度**AirGate 管控工具已完成核心功能开发Django 4.2 + DRF + Vue 3 + Element Plus
> 包括 IAM 子账号管理、额度划拨、阶梯式告警、消费监控、飞书通知。
> 项目仓库https://gitea.airlabs.art/seaislee/AirGate.git
>
> **待完成**创建子账号功能、权限策略配置界面、Docker/K8s 部署配置、飞书联调、AirDrama API 对接。