From 3213d6d98ae461a0a67b19ff3da4285a3a983714 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 19 Mar 2026 15:08:33 +0800 Subject: [PATCH] feat: complete AirGate core features + full audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 17 +- backend/Dockerfile | 26 + backend/apps/monitor/admin.py | 11 +- ...config_default_alert_threshold_and_more.py | 59 +++ ...lconfig_default_monthly_budget_and_more.py | 78 +++ backend/apps/monitor/models.py | 72 ++- backend/apps/monitor/permissions.py | 40 +- backend/apps/monitor/serializers.py | 60 ++- backend/apps/monitor/urls.py | 8 + backend/apps/monitor/views.py | 255 +++++++++- backend/config/settings.py | 19 +- backend/entrypoint.sh | 19 + backend/utils/iam_service.py | 28 +- backend/utils/scheduler.py | 161 ++++--- backend/utils/volcengine_client.py | 5 + docker-compose.yml | 27 ++ frontend/Dockerfile | 12 + frontend/nginx.conf | 18 + frontend/src/api/index.js | 2 +- frontend/src/views/billing/BillingView.vue | 45 +- .../src/views/dashboard/DashboardView.vue | 2 +- frontend/src/views/iam/IAMUserList.vue | 447 ++++++++++++++++-- frontend/src/views/settings/SettingsView.vue | 23 +- frontend/vite.config.js | 4 +- 火山引擎IAM子账号管控工具_深度研究报告.md | 243 ++++------ 25 files changed, 1309 insertions(+), 372 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/apps/monitor/migrations/0002_remove_globalconfig_default_alert_threshold_and_more.py create mode 100644 backend/apps/monitor/migrations/0003_remove_globalconfig_default_monthly_budget_and_more.py create mode 100644 backend/entrypoint.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/.env.example b/.env.example index dda2aa0..4b817cb 100644 --- a/.env.example +++ b/.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 不要写在这里! +# 启动后在浏览器「系统设置 → 添加主账号」中填入,会加密存入数据库。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..904e737 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/apps/monitor/admin.py b/backend/apps/monitor/admin.py index 089c851..b9ca733 100644 --- a/backend/apps/monitor/admin.py +++ b/backend/apps/monitor/admin.py @@ -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) diff --git a/backend/apps/monitor/migrations/0002_remove_globalconfig_default_alert_threshold_and_more.py b/backend/apps/monitor/migrations/0002_remove_globalconfig_default_alert_threshold_and_more.py new file mode 100644 index 0000000..24b40fd --- /dev/null +++ b/backend/apps/monitor/migrations/0002_remove_globalconfig_default_alert_threshold_and_more.py @@ -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='本月已触发的阈值'), + ), + ] diff --git a/backend/apps/monitor/migrations/0003_remove_globalconfig_default_monthly_budget_and_more.py b/backend/apps/monitor/migrations/0003_remove_globalconfig_default_monthly_budget_and_more.py new file mode 100644 index 0000000..96bd2d6 --- /dev/null +++ b/backend/apps/monitor/migrations/0003_remove_globalconfig_default_monthly_budget_and_more.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index 4bd0817..e629156 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -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) diff --git a/backend/apps/monitor/permissions.py b/backend/apps/monitor/permissions.py index 05e94c4..31b08e5 100644 --- a/backend/apps/monitor/permissions.py +++ b/backend/apps/monitor/permissions.py @@ -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) diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index 9b941c5..1bda373 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -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', diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 5e894c0..7937bfb 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -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//', views.iam_user_detail_view), @@ -19,6 +20,10 @@ urlpatterns = [ path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), path('iam-users//policies/', views.iam_user_policies_view), + path('iam-users//policies/attach/', views.iam_user_attach_policy_view), + path('iam-users//policies/detach/', views.iam_user_detach_policy_view), + path('iam-users//allocate/', views.quota_allocate_view), + path('iam-users//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), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 5ff6e78..9b87e09 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -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) diff --git a/backend/config/settings.py b/backend/config/settings.py index 8999549..3f970d2 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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')) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..21c83d9 --- /dev/null +++ b/backend/entrypoint.sh @@ -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 "$@" diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index 95612c4..5580d95 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -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 diff --git a/backend/utils/scheduler.py b/backend/utils/scheduler.py index f1a7d6b..269f9a5 100644 --- a/backend/utils/scheduler.py +++ b/backend/utils/scheduler.py @@ -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() diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py index 723e125..fe52120 100644 --- a/backend/utils/volcengine_client.py +++ b/backend/utils/volcengine_client.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce01bf3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..624bd28 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e56a400 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 215db77..0d52e5e 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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, }) diff --git a/frontend/src/views/billing/BillingView.vue b/frontend/src/views/billing/BillingView.vue index 9eab126..e972921 100644 --- a/frontend/src/views/billing/BillingView.vue +++ b/frontend/src/views/billing/BillingView.vue @@ -13,7 +13,7 @@ - @@ -21,33 +21,48 @@ + :default-sort="{ prop: 'consumed_total', order: 'descending' }"> - + - - + + - - - - + + + + + + + @@ -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' } diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue index c255c25..b115901 100644 --- a/frontend/src/views/dashboard/DashboardView.vue +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -23,7 +23,7 @@ - diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 4ccdfbe..9fae0fe 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -2,72 +2,150 @@

子账号管理

-
+
+ + 创建子账号 + - 同步火山用户 + 同步已有用户
- - - + + + - - + + + + - - - - - - - + + + + + + + - + + + +
+
用户: {{ allocateUser?.username }}
+
当前额度: ¥{{ Number(allocateUser?.allocated_quota || 0).toLocaleString() }}
+
已消费: ¥{{ Number(allocateUser?.consumed_total || 0).toLocaleString() }}
+
剩余: ¥{{ Number(allocateUser?.remaining_quota || 0).toLocaleString() }}
+
+ + + + 追加额度 + 扣减额度 + + + + +
+ 最多可扣减: ¥{{ maxDeduct.toLocaleString() }}(不能低于已消费金额) +
+
+ + + +
+
+ 变更后总额度: ¥{{ newTotalAfter.toLocaleString() }} +
+ +
+ - - - - -
留空则使用全局默认值
+ + + + + + +
+ 刷新项目列表 +
- - -
留空则使用全局默认值
+ + 告警阶梯 + + +
+ {{ step }}% + + 添加 +
+
达到已划拨额度对应百分比时发送告警
+ + 开关 + - + + {{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}
- - - - - - + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + 附加 +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ createdKeys.access_key_id }} + + + {{ createdKeys.secret_access_key }} + + + +
diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 9c13ec6..e922571 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -6,11 +6,11 @@ - - - - - + + +
+ 逗号分隔的百分比,如 50,80,90 表示消费达到已划拨额度的 50%/80%/90% 时告警 +
@@ -77,7 +77,7 @@