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:
parent
555c86ce76
commit
3213d6d98a
17
.env.example
17
.env.example
@ -16,19 +16,16 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# DB_USER=
|
||||
# DB_PASSWORD=
|
||||
|
||||
# 火山引擎主账号密钥(必填,用于管理 IAM 子账号)
|
||||
VOLC_ACCESS_KEY=
|
||||
VOLC_SECRET_KEY=
|
||||
|
||||
# 数据加密密钥(用于加密存储在数据库中的密钥)
|
||||
# 数据加密密钥(用于加密存储火山主账号 AK/SK)
|
||||
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
AIRGATE_ENCRYPTION_KEY=
|
||||
|
||||
# 飞书机器人 Webhook(选填,也可在管理界面中配置)
|
||||
FEISHU_WEBHOOK_URL=
|
||||
# 飞书机器人 Webhook(选填,也可在管理界面「系统设置」中配置)
|
||||
# FEISHU_WEBHOOK_URL=
|
||||
|
||||
# AirGate API Key(供外部系统如 AirDrama 调用本系统 API 时使用)
|
||||
AIRGATE_API_KEY=change-me-to-a-random-api-key
|
||||
# 不设置则 API Key 认证不生效,仅 JWT 认证可用
|
||||
# AIRGATE_API_KEY=
|
||||
|
||||
# 消费监控检查间隔(秒,默认 3600 = 1小时)
|
||||
MONITOR_INTERVAL=3600
|
||||
# 注意:火山主账号 AK/SK 不要写在这里!
|
||||
# 启动后在浏览器「系统设置 → 添加主账号」中填入,会加密存入数据库。
|
||||
|
||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal 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"]
|
||||
@ -1,5 +1,5 @@
|
||||
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)
|
||||
@ -10,13 +10,18 @@ class VolcAccountAdmin(admin.ModelAdmin):
|
||||
@admin.register(IAMUser)
|
||||
class IAMUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
||||
'current_month_spending', 'alert_threshold', 'disable_threshold')
|
||||
'allocated_quota', 'consumed_total')
|
||||
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)
|
||||
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)
|
||||
|
||||
@ -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='本月已触发的阈值'),
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,4 @@
|
||||
from decimal import Decimal
|
||||
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_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)
|
||||
# --- 额度管理(划拨制) ---
|
||||
allocated_quota = models.DecimalField('已划拨额度(元)', max_digits=12, decimal_places=2, default=0,
|
||||
help_text='主账号累计划拨给此子账号的总额度')
|
||||
consumed_total = models.DecimalField('累计消费(元)', max_digits=12, decimal_places=2, default=0,
|
||||
help_text='从 Billing API 获取的累计消费总额')
|
||||
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)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@ -66,23 +71,48 @@ class IAMUser(models.Model):
|
||||
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
|
||||
@property
|
||||
def remaining_quota(self):
|
||||
"""剩余额度"""
|
||||
return max(Decimal('0'), self.allocated_quota - self.consumed_total)
|
||||
|
||||
def get_disable_threshold(self):
|
||||
if self.disable_threshold is not None:
|
||||
return self.disable_threshold
|
||||
@property
|
||||
def usage_percent(self):
|
||||
"""额度使用率"""
|
||||
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()
|
||||
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):
|
||||
"""全局配置(单例)"""
|
||||
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)
|
||||
default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
|
||||
help_text='如 [50, 80, 90]')
|
||||
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)
|
||||
|
||||
@ -1,11 +1,43 @@
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
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):
|
||||
"""允许通过 X-API-Key 头认证(供外部系统如 AirDrama 调用)"""
|
||||
"""Permission check: require API Key authentication"""
|
||||
|
||||
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)
|
||||
return isinstance(request.user, APIKeyUser)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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):
|
||||
@ -16,29 +16,29 @@ class VolcAccountCreateSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class IAMUserSerializer(serializers.ModelSerializer):
|
||||
effective_alert_threshold = serializers.SerializerMethodField()
|
||||
effective_disable_threshold = serializers.SerializerMethodField()
|
||||
remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||
usage_percent = serializers.FloatField(read_only=True)
|
||||
effective_alert_thresholds = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = IAMUser
|
||||
fields = [
|
||||
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
||||
'project_name', 'status', 'access_key_ids',
|
||||
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
|
||||
'spending_updated_at',
|
||||
'monitor_enabled', 'auto_disable_enabled',
|
||||
'alert_threshold', 'disable_threshold',
|
||||
'effective_alert_threshold', 'effective_disable_threshold',
|
||||
'current_month_spending', 'spending_updated_at',
|
||||
'alert_thresholds', 'triggered_alerts',
|
||||
'effective_alert_thresholds',
|
||||
'remark', 'created_at', 'updated_at',
|
||||
]
|
||||
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']
|
||||
|
||||
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())
|
||||
def get_effective_alert_thresholds(self, obj):
|
||||
return obj.get_alert_thresholds()
|
||||
|
||||
|
||||
class IAMUserCreateSerializer(serializers.Serializer):
|
||||
@ -48,30 +48,46 @@ class IAMUserCreateSerializer(serializers.Serializer):
|
||||
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)
|
||||
class IAMUserConfigSerializer(serializers.Serializer):
|
||||
"""子账号配置更新"""
|
||||
project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||
alert_thresholds = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1, max_value=99),
|
||||
required=False,
|
||||
)
|
||||
monitor_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 Meta:
|
||||
model = GlobalConfig
|
||||
fields = [
|
||||
'default_alert_threshold', 'default_disable_threshold',
|
||||
'default_alert_thresholds',
|
||||
'monitor_interval_seconds',
|
||||
'feishu_webhook_url', 'feishu_alert_mobiles',
|
||||
'updated_at',
|
||||
|
||||
@ -12,6 +12,7 @@ urlpatterns = [
|
||||
|
||||
# IAM user management
|
||||
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/import/', views.iam_user_import_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>/enable/', views.iam_user_enable_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
|
||||
path('billing/overview/', views.spending_overview_view),
|
||||
@ -30,4 +35,7 @@ urlpatterns = [
|
||||
|
||||
# Alerts
|
||||
path('alerts/', views.alert_list_view),
|
||||
|
||||
# Projects
|
||||
path('projects/', views.project_list_view),
|
||||
]
|
||||
|
||||
@ -7,19 +7,18 @@ 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.iam_service import IAMService, ProjectService
|
||||
from utils.billing_service import BillingService
|
||||
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 (
|
||||
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||
IAMUserThresholdSerializer,
|
||||
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
GlobalConfigSerializer,
|
||||
AlertRecordSerializer,
|
||||
DashboardSerializer,
|
||||
@ -50,7 +49,7 @@ def dashboard_view(request):
|
||||
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')
|
||||
total=Sum('consumed_total'))['total'] or Decimal('0')
|
||||
recent_alerts = AlertRecord.objects.all()[:10]
|
||||
|
||||
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'])
|
||||
def iam_user_import_view(request):
|
||||
"""导入指定的已有 IAM 用户"""
|
||||
@ -263,7 +344,7 @@ def iam_user_update_view(request, pk):
|
||||
except IAMUser.DoesNotExist:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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 ====================
|
||||
|
||||
@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')
|
||||
users = IAMUser.objects.all().order_by('-consumed_total')
|
||||
return Response({
|
||||
'period': bill_period,
|
||||
'users': IAMUserSerializer(users, many=True).data,
|
||||
@ -417,3 +632,29 @@ def alert_list_view(request):
|
||||
alerts = alerts.filter(alert_type=alert_type)
|
||||
limit = int(request.query_params.get('limit', 50))
|
||||
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)
|
||||
|
||||
@ -5,8 +5,13 @@ 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')
|
||||
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(',')
|
||||
|
||||
# --- Apps ---
|
||||
@ -73,10 +78,13 @@ if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
'NAME': db_dir / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +99,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'apps.monitor.permissions.APIKeyAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
@ -130,10 +139,6 @@ 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', '')
|
||||
|
||||
@ -143,5 +148,3 @@ 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/entrypoint.sh
Normal file
19
backend/entrypoint.sh
Normal 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 "$@"
|
||||
@ -1,7 +1,7 @@
|
||||
"""IAM 子账号管理服务"""
|
||||
|
||||
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__)
|
||||
|
||||
@ -118,3 +118,29 @@ class IAMService:
|
||||
|
||||
if 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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""定时消费监控任务"""
|
||||
"""定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -10,15 +10,15 @@ _scheduler_started = False
|
||||
|
||||
|
||||
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.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()
|
||||
webhook = config.feishu_webhook_url
|
||||
|
||||
for volc_account in VolcAccount.objects.filter(is_active=True):
|
||||
ak = decrypt(volc_account.access_key_enc)
|
||||
@ -37,87 +37,105 @@ def check_spending():
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# 查询当月消费(按项目筛选)
|
||||
bill_period = timezone.now().strftime("%Y-%m")
|
||||
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()
|
||||
# 记录月度快照
|
||||
SpendingRecord.objects.update_or_create(
|
||||
iam_user=user, bill_period=bill_period,
|
||||
defaults={'amount': spending},
|
||||
)
|
||||
|
||||
# Check disable threshold
|
||||
if (user.auto_disable_enabled
|
||||
and disable_threshold
|
||||
and spending >= disable_threshold):
|
||||
# 累计消费 = 所有月份的消费之和
|
||||
from django.db.models import Sum
|
||||
total = SpendingRecord.objects.filter(
|
||||
iam_user=user
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
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()
|
||||
user.consumed_total = total
|
||||
user.spending_updated_at = timezone.now()
|
||||
|
||||
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}")
|
||||
quota = user.allocated_quota
|
||||
if not quota or quota <= 0:
|
||||
user.save(update_fields=['consumed_total', 'spending_updated_at'])
|
||||
continue
|
||||
|
||||
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'])
|
||||
usage_percent = float(total) / float(quota) * 100
|
||||
triggered = user.triggered_alerts or []
|
||||
|
||||
# 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()
|
||||
# --- 阶梯式告警 ---
|
||||
for step in user.get_alert_thresholds():
|
||||
if usage_percent >= step and step not in triggered:
|
||||
triggered.append(step)
|
||||
threshold_amount = Decimal(str(quota)) * step / 100
|
||||
|
||||
if not already_alerted:
|
||||
alert = AlertRecord.objects.create(
|
||||
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,
|
||||
title=f"{user.username} 消费达到额度 {step}%",
|
||||
content=(
|
||||
f"累计消费 ¥{total:.2f},"
|
||||
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(
|
||||
webhook,
|
||||
"⚠️ 子账号消费告警",
|
||||
f"⚠️ {user.username} 消费达到额度 {step}%",
|
||||
f"**用户**: {user.username}\n"
|
||||
f"**消费**: ¥{spending:.2f}\n"
|
||||
f"**告警阈值**: ¥{alert_threshold:.2f}\n"
|
||||
f"**停用阈值**: ¥{disable_threshold:.2f}",
|
||||
template="orange",
|
||||
f"**累计消费**: ¥{total:.2f}\n"
|
||||
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
|
||||
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:
|
||||
logger.error(f"检查用户 {user.username} 消费失败: {e}")
|
||||
@ -132,10 +150,11 @@ def start_scheduler():
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from django.conf import settings
|
||||
from apps.monitor.models import GlobalConfig
|
||||
|
||||
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,
|
||||
id='check_spending', replace_existing=True)
|
||||
scheduler.start()
|
||||
|
||||
@ -113,3 +113,8 @@ def get_iam_client(ak: str, sk: str) -> VolcengineClient:
|
||||
def get_billing_client(ak: str, sk: str) -> VolcengineClient:
|
||||
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
|
||||
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
27
docker-compose.yml
Normal 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
12
frontend/Dockerfile
Normal 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
18
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { useAuthStore } from '../stores/auth'
|
||||
import router from '../router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8100',
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<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)"
|
||||
<el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || balance.availableBalance || 0)"
|
||||
:precision="2" prefix="¥" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
@ -21,33 +21,48 @@
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>各子账号本月消费</span>
|
||||
<span>各子账号消费与额度</span>
|
||||
</template>
|
||||
<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="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>
|
||||
<el-table-column prop="consumed_total" label="累计消费" width="140" sortable>
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight: 600; color: #e6a23c;">
|
||||
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
||||
¥{{ Number(row.consumed_total).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警阈值" width="120">
|
||||
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template>
|
||||
<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 }">¥{{ row.effective_disable_threshold }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消费进度" min-width="200">
|
||||
<el-table-column label="剩余额度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="Math.min(100, (Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100))"
|
||||
<span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
|
||||
¥{{ 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)"
|
||||
: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>
|
||||
</el-table-column>
|
||||
<el-table-column prop="spending_updated_at" label="更新时间" width="180">
|
||||
@ -105,8 +120,8 @@ async function loadBalance() {
|
||||
}
|
||||
|
||||
function progressColor(row) {
|
||||
const pct = Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100
|
||||
if (pct >= 80) return '#f56c6c'
|
||||
const pct = row.usage_percent || 0
|
||||
if (pct >= 90) return '#f56c6c'
|
||||
if (pct >= 50) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<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="¥" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
@ -2,72 +2,150 @@
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<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-icon><Refresh /></el-icon> 同步火山用户
|
||||
<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">
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="display_name" label="显示名" width="110" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<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">
|
||||
<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 }">
|
||||
<span :style="{ color: Number(row.current_month_spending) > 0 ? '#e6a23c' : '' }">
|
||||
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
||||
<span :style="{ color: Number(row.consumed_total) > 0 ? '#e6a23c' : '' }">
|
||||
¥{{ Number(row.consumed_total).toLocaleString() }}
|
||||
</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">
|
||||
<el-table-column label="剩余额度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.monitor_enabled ? 'success' : 'info'" size="small">
|
||||
{{ row.monitor_enabled ? '开' : '关' }}
|
||||
<span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
|
||||
¥{{ 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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<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 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>
|
||||
<el-button size="small" @click="openPolicies(row)">权限</el-button>
|
||||
<el-button size="small" @click="openQuotaHistory(row)">记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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 -->
|
||||
<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-dialog v-model="configVisible" title="监控配置" width="560px">
|
||||
<el-form :model="configForm" label-width="130px">
|
||||
<el-form-item label="关联项目">
|
||||
<el-select v-model="configForm.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>
|
||||
<div class="form-hint">
|
||||
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
|
||||
</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-divider content-position="left">告警阶梯</el-divider>
|
||||
|
||||
<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-divider content-position="left">开关</el-divider>
|
||||
|
||||
<el-form-item label="消费监控">
|
||||
<el-switch v-model="configForm.monitor_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="自动停用">
|
||||
<el-form-item label="额度用尽自动停用">
|
||||
<el-switch v-model="configForm.auto_disable_enabled" />
|
||||
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@ -76,19 +154,109 @@
|
||||
</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="说明" />
|
||||
<!-- Quota History Dialog -->
|
||||
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
|
||||
<el-table :data="quotaHistory" stripe v-loading="historyLoading" 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="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-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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
@ -96,14 +264,35 @@ const users = ref([])
|
||||
const loading = 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 configForm = ref({})
|
||||
const configUserId = ref(null)
|
||||
const saving = ref(false)
|
||||
const newStep = ref(null)
|
||||
|
||||
const policiesVisible = ref(false)
|
||||
const policies = ref([])
|
||||
const policiesLoading = ref(false)
|
||||
// Projects
|
||||
const projects = ref([])
|
||||
const projectsLoading = ref(false)
|
||||
|
||||
// Quota History
|
||||
const historyVisible = ref(false)
|
||||
const historyUser = ref(null)
|
||||
const quotaHistory = ref([])
|
||||
const historyLoading = ref(false)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
@ -131,7 +320,9 @@ async function handleSync() {
|
||||
}
|
||||
|
||||
async function handleDisable(row) {
|
||||
await ElMessageBox.confirm(`确定要停用子账号 "${row.username}" 吗?停用后该账号的控制台和 API 访问都将被禁止。`, '确认停用', { type: 'warning' })
|
||||
await ElMessageBox.confirm(
|
||||
`确定要停用子账号 "${row.username}" 吗?`, '确认停用', { type: 'warning' }
|
||||
)
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${row.id}/disable/`)
|
||||
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) {
|
||||
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,
|
||||
project_name: row.project_name || '',
|
||||
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
|
||||
monitor_enabled: row.monitor_enabled,
|
||||
auto_disable_enabled: row.auto_disable_enabled,
|
||||
}
|
||||
newStep.value = null
|
||||
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() {
|
||||
@ -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
|
||||
policiesLoading.value = true
|
||||
policyToAttach.value = ''
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/iam-users/${row.id}/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)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-hint { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.switch-hint { font-size: 12px; color: #999; margin-left: 8px; }
|
||||
</style>
|
||||
|
||||
@ -6,11 +6,11 @@
|
||||
<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 label="默认告警阶梯(%)">
|
||||
<el-input v-model="alertThresholdsStr" placeholder="50,80,90" />
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||
逗号分隔的百分比,如 50,80,90 表示消费达到已划拨额度的 50%/80%/90% 时告警
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="监控间隔(秒)">
|
||||
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
||||
@ -77,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
@ -86,6 +86,17 @@ const config = ref({})
|
||||
const loadingConfig = 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
|
||||
const accounts = ref([])
|
||||
const loadingAccounts = ref(false)
|
||||
|
||||
@ -4,10 +4,10 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8100',
|
||||
target: 'http://localhost:8101',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@ -31,10 +31,10 @@
|
||||
|------|----------|--------|
|
||||
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
|
||||
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
|
||||
| 子账号能看到自己的账单 | 通过项目 + 标签维度,主账号代查并展示 | **部分可行**(见下方说明)|
|
||||
| 子账号能看到自己的账单 | 通过 AirGate 按项目维度查询,主账号代查展示 | **部分可行**(见下方说明)|
|
||||
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
|
||||
| 消费达到阈值发告警 | 定时轮询 Billing API + Webhook/飞书通知 | **完全可行** |
|
||||
| 消费达到阈值自动停用 | 轮询消费 + 调用 IAM API 停用用户和密钥 | **完全可行** |
|
||||
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** |
|
||||
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
|
||||
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** |
|
||||
|
||||
### 1.2 架构图
|
||||
@ -594,21 +594,34 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
- 仅支持在线推理(不含批量)
|
||||
- 目前仅支持通过控制台设置,**暂无公开 API**
|
||||
|
||||
### 7.4 自建告警方案(推荐)
|
||||
### 7.4 AirGate 自建方案:额度划拨制 + 阶梯式告警
|
||||
|
||||
由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。需要自建:
|
||||
由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。AirGate 采用**额度划拨制**自建:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 定时任务 (每小时/每天) │
|
||||
│ │
|
||||
│ 1. 调用 ListBillDetail 查询各子账号消费 │
|
||||
│ 2. 与预设阈值对比 │
|
||||
│ 3. 达到告警阈值 → 发送通知 │
|
||||
│ 4. 达到停用阈值 → 调用 IAM API 停用用户 │
|
||||
└──────────────────────────────────────────────┘
|
||||
主账号通过 AirGate 给子账号划拨额度(如 10 万元)
|
||||
│
|
||||
▼ 定时任务每小时查询 Billing API
|
||||
累计消费不断增长,对比已划拨额度
|
||||
│
|
||||
├── 消费达到额度 50% → 飞书告警
|
||||
├── 消费达到额度 80% → 飞书告警
|
||||
├── 消费达到额度 90% → 飞书告警
|
||||
└── 消费达到额度 100% → 自动停用子账号 + 飞书告警
|
||||
|
||||
额度用完 → 主账号在 AirGate 追加额度 → 告警状态自动重置 → 恢复子账号
|
||||
额度给多了 → 主账号在 AirGate 扣减额度 → 告警状态自动重置
|
||||
```
|
||||
|
||||
**关键设计:**
|
||||
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
|
||||
- **可追加可扣减**:主账号可随时追加额度(+5万)或扣减额度(-3万),支持灵活调整
|
||||
- **扣减保护**:扣减后总额度不能低于已消费金额(否则会立即触发停用)
|
||||
- **阶梯式告警**:每个子账号可自定义告警百分比(如 [50, 80, 90]),每档只通知一次
|
||||
- **额度变更即重置告警**:追加或扣减额度后,已触发的告警状态自动清空,按新的使用率重新计算
|
||||
- **累计消费**:跨月累计,通过 Billing API 各月数据求和得出
|
||||
- **操作留痕**:每次划拨/扣减都记录操作人、金额、备注,可追溯
|
||||
|
||||
---
|
||||
|
||||
## 8. 子账号自动停用/恢复方案
|
||||
@ -915,10 +928,9 @@ iam.call("AttachUserPolicy", {
|
||||
#### Step 4:创建项目并分配
|
||||
|
||||
```python
|
||||
# 使用项目管理 API(通过 open.volcengineapi.com)
|
||||
# 注意:项目管理的 service 签名名称需要根据实际情况确认,
|
||||
# 可能是 "resource_manager" 或其他名称,建议先用 API Explorer 测试
|
||||
project_client = VolcengineClient(AK, SK, "resource_manager", "open.volcengineapi.com")
|
||||
# 项目管理 API(与 IAM 共用端点,但 Version 不同)
|
||||
project_client = VolcengineClient(AK, SK, "iam", "iam.volcengineapi.com",
|
||||
version="2021-08-01")
|
||||
|
||||
# 创建项目
|
||||
project_client.call("CreateProject", {
|
||||
@ -982,153 +994,65 @@ def get_user_spending(bill_period: str, project_name: str = None) -> float:
|
||||
return total
|
||||
```
|
||||
|
||||
### 第三阶段:告警与自动停用
|
||||
### 第三阶段:告警与自动停用(已在 AirGate 中实现)
|
||||
|
||||
#### Step 6:消费监控与告警服务
|
||||
> 以下逻辑已通过 AirGate 管理平台实现,不再需要手写脚本。
|
||||
> 详见 `backend/utils/scheduler.py` 和 `backend/apps/monitor/views.py`。
|
||||
|
||||
```python
|
||||
import time
|
||||
import logging
|
||||
import requests as http_requests
|
||||
**AirGate 实现的核心流程:**
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("volcengine_monitor")
|
||||
1. 主账号通过界面给子账号**划拨额度**(如 10 万元)
|
||||
2. 定时任务每小时调用 Billing API 查询累计消费
|
||||
3. 消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
|
||||
4. 消费达到 100% → 自动停用子账号 + 飞书告警
|
||||
5. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
|
||||
|
||||
# 配置
|
||||
ALERT_THRESHOLD = 1000.0 # 告警阈值(元)
|
||||
DISABLE_THRESHOLD = 5000.0 # 停用阈值(元)
|
||||
CHECK_INTERVAL = 3600 # 检查间隔(秒),每小时
|
||||
**告警状态管理:**
|
||||
- 每个阶梯只通知一次,通过 `triggered_alerts` 字段(存数据库)去重
|
||||
- 追加额度时自动重置 `triggered_alerts`,按新使用率重新计算
|
||||
- 不需要月度重置,因为是额度制而非月度制
|
||||
|
||||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK_ID"
|
||||
|
||||
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 接口设计
|
||||
#### Step 6:AirGate 已实现的 API 接口
|
||||
|
||||
```
|
||||
# 用户管理
|
||||
GET /api/iam/users # 列出所有子账号
|
||||
POST /api/iam/users # 创建子账号
|
||||
GET /api/iam/users/{username} # 查询子账号详情
|
||||
PUT /api/iam/users/{username} # 更新子账号
|
||||
DELETE /api/iam/users/{username} # 删除子账号
|
||||
# 仪表盘
|
||||
GET /api/v1/dashboard/ # 总览(用户数/消费/告警)
|
||||
|
||||
# 启停控制
|
||||
POST /api/iam/users/{username}/disable # 停用子账号
|
||||
POST /api/iam/users/{username}/enable # 恢复子账号
|
||||
# 火山主账号管理
|
||||
GET /api/v1/volc-accounts/ # 列出主账号
|
||||
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/ # 测试密钥有效性
|
||||
|
||||
# 权限管理
|
||||
GET /api/iam/users/{username}/policies # 查看子账号权限
|
||||
POST /api/iam/users/{username}/policies # 附加权限
|
||||
DELETE /api/iam/users/{username}/policies # 移除权限
|
||||
# IAM 子账号管理
|
||||
GET /api/v1/iam-users/ # 列出所有子账号
|
||||
POST /api/v1/iam-users/sync/ # 从火山同步全部子账号
|
||||
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/iam/users/{username}/access-keys # 创建密钥
|
||||
PUT /api/iam/users/{username}/access-keys/{id} # 启停密钥
|
||||
# 额度管理
|
||||
POST /api/v1/iam-users/{id}/allocate/ # 追加额度(正数)或扣减额度(负数)
|
||||
GET /api/v1/iam-users/{id}/quota-history/ # 查看额度变更记录(含追加和扣减)
|
||||
|
||||
# 消费查询
|
||||
GET /api/billing/users/{username}/spending # 查询子账号消费
|
||||
GET /api/billing/overview # 消费总览
|
||||
GET /api/v1/billing/overview/ # 消费总览
|
||||
POST /api/v1/billing/refresh/ # 手动刷新消费数据
|
||||
GET /api/v1/billing/balance/ # 主账号余额
|
||||
|
||||
# 告警配置
|
||||
GET /api/alerts/config # 查看告警配置
|
||||
PUT /api/alerts/config # 更新阈值配置
|
||||
GET /api/alerts/history # 告警历史
|
||||
# 全局配置
|
||||
GET /api/v1/config/ # 查看全局配置
|
||||
PUT /api/v1/config/ # 更新全局配置
|
||||
|
||||
# 告警记录
|
||||
GET /api/v1/alerts/ # 告警历史(支持类型筛选)
|
||||
|
||||
# 项目列表
|
||||
GET /api/v1/projects/ # 从火山拉取项目列表
|
||||
```
|
||||
|
||||
---
|
||||
@ -1145,7 +1069,7 @@ GET /api/alerts/history # 告警历史
|
||||
| SecretKey 仅返回一次 | 创建后立即保存 |
|
||||
| Billing API QPS 限制 5 | 批量查询需注意限流 |
|
||||
| Ark 推理限额无公开 API | 目前仅支持控制台操作 |
|
||||
| 预算告警仅通知不自动执行 | 需自建自动停用逻辑 |
|
||||
| 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
|
||||
|
||||
### 12.2 安全建议
|
||||
|
||||
@ -1157,10 +1081,11 @@ GET /api/alerts/history # 告警历史
|
||||
|
||||
### 12.3 消费监控的精确度问题
|
||||
|
||||
由于账单数据有 1-2 天延迟,消费监控存在滞后。应对策略:
|
||||
- 设置更保守的阈值(如实际想控制 5000 元,告警阈值设 3000,停用阈值设 4000)
|
||||
- 结合 Ark 推理限额功能(自动暂停,无延迟)
|
||||
- 高频轮询(如每小时)以尽早发现异常
|
||||
由于账单数据有 1-2 天延迟,消费监控存在滞后。AirGate 的应对策略:
|
||||
- **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90])
|
||||
- **阶梯式告警**:在额度用尽前的多个节点提前告警,给管理员反应时间
|
||||
- **高频轮询**:每小时查一次,虽然数据本身有延迟,但能在数据更新后第一时间触发告警
|
||||
- 结合 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 对接。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user