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_USER=
|
||||||
# DB_PASSWORD=
|
# DB_PASSWORD=
|
||||||
|
|
||||||
# 火山引擎主账号密钥(必填,用于管理 IAM 子账号)
|
# 数据加密密钥(用于加密存储火山主账号 AK/SK)
|
||||||
VOLC_ACCESS_KEY=
|
|
||||||
VOLC_SECRET_KEY=
|
|
||||||
|
|
||||||
# 数据加密密钥(用于加密存储在数据库中的密钥)
|
|
||||||
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
AIRGATE_ENCRYPTION_KEY=
|
AIRGATE_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# 飞书机器人 Webhook(选填,也可在管理界面中配置)
|
# 飞书机器人 Webhook(选填,也可在管理界面「系统设置」中配置)
|
||||||
FEISHU_WEBHOOK_URL=
|
# FEISHU_WEBHOOK_URL=
|
||||||
|
|
||||||
# AirGate API Key(供外部系统如 AirDrama 调用本系统 API 时使用)
|
# AirGate API Key(供外部系统如 AirDrama 调用本系统 API 时使用)
|
||||||
AIRGATE_API_KEY=change-me-to-a-random-api-key
|
# 不设置则 API Key 认证不生效,仅 JWT 认证可用
|
||||||
|
# AIRGATE_API_KEY=
|
||||||
|
|
||||||
# 消费监控检查间隔(秒,默认 3600 = 1小时)
|
# 注意:火山主账号 AK/SK 不要写在这里!
|
||||||
MONITOR_INTERVAL=3600
|
# 启动后在浏览器「系统设置 → 添加主账号」中填入,会加密存入数据库。
|
||||||
|
|||||||
26
backend/Dockerfile
Normal file
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 django.contrib import admin
|
||||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||||
|
|
||||||
|
|
||||||
@admin.register(VolcAccount)
|
@admin.register(VolcAccount)
|
||||||
@ -10,13 +10,18 @@ class VolcAccountAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(IAMUser)
|
@admin.register(IAMUser)
|
||||||
class IAMUserAdmin(admin.ModelAdmin):
|
class IAMUserAdmin(admin.ModelAdmin):
|
||||||
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
||||||
'current_month_spending', 'alert_threshold', 'disable_threshold')
|
'allocated_quota', 'consumed_total')
|
||||||
list_filter = ('status', 'monitor_enabled')
|
list_filter = ('status', 'monitor_enabled')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(QuotaAllocation)
|
||||||
|
class QuotaAllocationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('iam_user', 'amount', 'total_after', 'created_by', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GlobalConfig)
|
@admin.register(GlobalConfig)
|
||||||
class GlobalConfigAdmin(admin.ModelAdmin):
|
class GlobalConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = ('default_alert_threshold', 'default_disable_threshold', 'monitor_interval_seconds')
|
list_display = ('monitor_interval_seconds', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AlertRecord)
|
@admin.register(AlertRecord)
|
||||||
|
|||||||
@ -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
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
@ -41,18 +42,22 @@ class IAMUser(models.Model):
|
|||||||
# Access keys (stored as JSON list of AK IDs, not secrets)
|
# Access keys (stored as JSON list of AK IDs, not secrets)
|
||||||
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
||||||
|
|
||||||
# Monitoring config
|
# --- 额度管理(划拨制) ---
|
||||||
monitor_enabled = models.BooleanField('启用消费监控', default=True)
|
allocated_quota = models.DecimalField('已划拨额度(元)', max_digits=12, decimal_places=2, default=0,
|
||||||
auto_disable_enabled = models.BooleanField('启用自动停用', default=True)
|
help_text='主账号累计划拨给此子账号的总额度')
|
||||||
alert_threshold = models.DecimalField('告警阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True,
|
consumed_total = models.DecimalField('累计消费(元)', max_digits=12, decimal_places=2, default=0,
|
||||||
help_text='为空则使用全局默认值')
|
help_text='从 Billing API 获取的累计消费总额')
|
||||||
disable_threshold = models.DecimalField('停用阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True,
|
|
||||||
help_text='为空则使用全局默认值')
|
|
||||||
|
|
||||||
# Spending cache
|
|
||||||
current_month_spending = models.DecimalField('本月消费(元)', max_digits=12, decimal_places=2, default=0)
|
|
||||||
spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
|
spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
|
||||||
|
|
||||||
|
# --- 监控配置 ---
|
||||||
|
monitor_enabled = models.BooleanField('启用消费监控', default=True)
|
||||||
|
alert_thresholds = models.JSONField('告警阈值(百分比列表)', default=list, blank=True,
|
||||||
|
help_text='如 [50, 80, 90] 表示消费达到额度的 50%/80%/90% 时告警')
|
||||||
|
auto_disable_enabled = models.BooleanField('额度用尽自动停用', default=True,
|
||||||
|
help_text='消费达到已划拨额度 100% 时自动停用')
|
||||||
|
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
|
||||||
|
help_text='记录已通知过的百分比,划拨新额度时自动重置')
|
||||||
|
|
||||||
remark = models.TextField('备注', blank=True)
|
remark = models.TextField('备注', blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@ -66,23 +71,48 @@ class IAMUser(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.display_name or self.username} ({self.status})"
|
return f"{self.display_name or self.username} ({self.status})"
|
||||||
|
|
||||||
def get_alert_threshold(self):
|
@property
|
||||||
if self.alert_threshold is not None:
|
def remaining_quota(self):
|
||||||
return self.alert_threshold
|
"""剩余额度"""
|
||||||
config = GlobalConfig.get_solo()
|
return max(Decimal('0'), self.allocated_quota - self.consumed_total)
|
||||||
return config.default_alert_threshold
|
|
||||||
|
|
||||||
def get_disable_threshold(self):
|
@property
|
||||||
if self.disable_threshold is not None:
|
def usage_percent(self):
|
||||||
return self.disable_threshold
|
"""额度使用率"""
|
||||||
|
if self.allocated_quota <= 0:
|
||||||
|
return 0
|
||||||
|
return round(float(self.consumed_total) / float(self.allocated_quota) * 100, 1)
|
||||||
|
|
||||||
|
def get_alert_thresholds(self):
|
||||||
|
if self.alert_thresholds:
|
||||||
|
return sorted(self.alert_thresholds)
|
||||||
config = GlobalConfig.get_solo()
|
config = GlobalConfig.get_solo()
|
||||||
return config.default_disable_threshold
|
return sorted(config.default_alert_thresholds or [50, 80, 90])
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaAllocation(models.Model):
|
||||||
|
"""额度划拨记录"""
|
||||||
|
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
|
||||||
|
amount = models.DecimalField('变更金额(元,正=追加,负=扣减)', max_digits=12, decimal_places=2)
|
||||||
|
total_after = models.DecimalField('划拨后总额度', max_digits=12, decimal_places=2)
|
||||||
|
note = models.CharField('备注', max_length=500, blank=True)
|
||||||
|
created_by = models.CharField('操作人', max_length=100, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '额度划拨记录'
|
||||||
|
verbose_name_plural = '额度划拨记录'
|
||||||
|
db_table = 'airgate_quota_allocation'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.iam_user.username} +¥{self.amount} → ¥{self.total_after}"
|
||||||
|
|
||||||
|
|
||||||
class GlobalConfig(models.Model):
|
class GlobalConfig(models.Model):
|
||||||
"""全局配置(单例)"""
|
"""全局配置(单例)"""
|
||||||
default_alert_threshold = models.DecimalField('默认告警阈值(元)', max_digits=12, decimal_places=2, default=1000)
|
default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
|
||||||
default_disable_threshold = models.DecimalField('默认停用阈值(元)', max_digits=12, decimal_places=2, default=5000)
|
help_text='如 [50, 80, 90]')
|
||||||
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600)
|
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600)
|
||||||
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
|
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True)
|
||||||
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)
|
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True)
|
||||||
|
|||||||
@ -1,11 +1,43 @@
|
|||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyUser:
|
||||||
|
"""Represents an external system authenticated via API Key"""
|
||||||
|
pk = None
|
||||||
|
id = None
|
||||||
|
is_authenticated = True
|
||||||
|
is_active = True
|
||||||
|
is_staff = False
|
||||||
|
is_superuser = False
|
||||||
|
username = 'api_key_user'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'APIKeyUser'
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyAuthentication(BaseAuthentication):
|
||||||
|
"""Authenticate requests via X-API-Key header (for external systems like AirDrama)"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
api_key = request.headers.get('X-API-Key', '')
|
||||||
|
if not api_key:
|
||||||
|
return None # Let other auth methods try
|
||||||
|
|
||||||
|
expected = settings.AIRGATE_API_KEY
|
||||||
|
if not expected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if api_key != expected:
|
||||||
|
raise AuthenticationFailed('API Key 无效')
|
||||||
|
|
||||||
|
return (APIKeyUser(), None)
|
||||||
|
|
||||||
|
|
||||||
class IsAPIKeyAuth(BasePermission):
|
class IsAPIKeyAuth(BasePermission):
|
||||||
"""允许通过 X-API-Key 头认证(供外部系统如 AirDrama 调用)"""
|
"""Permission check: require API Key authentication"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
api_key = request.headers.get('X-API-Key', '')
|
return isinstance(request.user, APIKeyUser)
|
||||||
expected = settings.AIRGATE_API_KEY
|
|
||||||
return bool(expected and api_key == expected)
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord
|
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||||
|
|
||||||
|
|
||||||
class VolcAccountSerializer(serializers.ModelSerializer):
|
class VolcAccountSerializer(serializers.ModelSerializer):
|
||||||
@ -16,29 +16,29 @@ class VolcAccountCreateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class IAMUserSerializer(serializers.ModelSerializer):
|
class IAMUserSerializer(serializers.ModelSerializer):
|
||||||
effective_alert_threshold = serializers.SerializerMethodField()
|
remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||||
effective_disable_threshold = serializers.SerializerMethodField()
|
usage_percent = serializers.FloatField(read_only=True)
|
||||||
|
effective_alert_thresholds = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IAMUser
|
model = IAMUser
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
||||||
'project_name', 'status', 'access_key_ids',
|
'project_name', 'status', 'access_key_ids',
|
||||||
|
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
|
||||||
|
'spending_updated_at',
|
||||||
'monitor_enabled', 'auto_disable_enabled',
|
'monitor_enabled', 'auto_disable_enabled',
|
||||||
'alert_threshold', 'disable_threshold',
|
'alert_thresholds', 'triggered_alerts',
|
||||||
'effective_alert_threshold', 'effective_disable_threshold',
|
'effective_alert_thresholds',
|
||||||
'current_month_spending', 'spending_updated_at',
|
|
||||||
'remark', 'created_at', 'updated_at',
|
'remark', 'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||||
'current_month_spending', 'spending_updated_at',
|
'consumed_total', 'spending_updated_at',
|
||||||
|
'triggered_alerts',
|
||||||
'created_at', 'updated_at']
|
'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_effective_alert_threshold(self, obj):
|
def get_effective_alert_thresholds(self, obj):
|
||||||
return str(obj.get_alert_threshold())
|
return obj.get_alert_thresholds()
|
||||||
|
|
||||||
def get_effective_disable_threshold(self, obj):
|
|
||||||
return str(obj.get_disable_threshold())
|
|
||||||
|
|
||||||
|
|
||||||
class IAMUserCreateSerializer(serializers.Serializer):
|
class IAMUserCreateSerializer(serializers.Serializer):
|
||||||
@ -48,30 +48,46 @@ class IAMUserCreateSerializer(serializers.Serializer):
|
|||||||
phone = serializers.CharField(max_length=20, required=False, default='')
|
phone = serializers.CharField(max_length=20, required=False, default='')
|
||||||
password = serializers.CharField(write_only=True, required=False, default='')
|
password = serializers.CharField(write_only=True, required=False, default='')
|
||||||
project_name = serializers.CharField(max_length=200, required=False, default='')
|
project_name = serializers.CharField(max_length=200, required=False, default='')
|
||||||
alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
|
||||||
required=False, allow_null=True)
|
|
||||||
disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
|
||||||
required=False, allow_null=True)
|
|
||||||
|
|
||||||
|
|
||||||
class IAMUserImportSerializer(serializers.Serializer):
|
class IAMUserImportSerializer(serializers.Serializer):
|
||||||
username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名')
|
username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名')
|
||||||
|
|
||||||
|
|
||||||
class IAMUserThresholdSerializer(serializers.Serializer):
|
class IAMUserConfigSerializer(serializers.Serializer):
|
||||||
alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
"""子账号配置更新"""
|
||||||
required=False, allow_null=True)
|
project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2,
|
alert_thresholds = serializers.ListField(
|
||||||
required=False, allow_null=True)
|
child=serializers.IntegerField(min_value=1, max_value=99),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
monitor_enabled = serializers.BooleanField(required=False)
|
monitor_enabled = serializers.BooleanField(required=False)
|
||||||
auto_disable_enabled = serializers.BooleanField(required=False)
|
auto_disable_enabled = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaAllocateSerializer(serializers.Serializer):
|
||||||
|
"""额度变更:正数=追加,负数=扣减"""
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
note = serializers.CharField(max_length=500, required=False, default='')
|
||||||
|
|
||||||
|
def validate_amount(self, value):
|
||||||
|
from decimal import Decimal
|
||||||
|
if value == Decimal('0'):
|
||||||
|
raise serializers.ValidationError('变更金额不能为 0')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaAllocationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = QuotaAllocation
|
||||||
|
fields = ['id', 'amount', 'total_after', 'note', 'created_by', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
class GlobalConfigSerializer(serializers.ModelSerializer):
|
class GlobalConfigSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = GlobalConfig
|
model = GlobalConfig
|
||||||
fields = [
|
fields = [
|
||||||
'default_alert_threshold', 'default_disable_threshold',
|
'default_alert_thresholds',
|
||||||
'monitor_interval_seconds',
|
'monitor_interval_seconds',
|
||||||
'feishu_webhook_url', 'feishu_alert_mobiles',
|
'feishu_webhook_url', 'feishu_alert_mobiles',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# IAM user management
|
# IAM user management
|
||||||
path('iam-users/', views.iam_user_list_view),
|
path('iam-users/', views.iam_user_list_view),
|
||||||
|
path('iam-users/create/', views.iam_user_create_view),
|
||||||
path('iam-users/sync/', views.iam_user_sync_view),
|
path('iam-users/sync/', views.iam_user_sync_view),
|
||||||
path('iam-users/import/', views.iam_user_import_view),
|
path('iam-users/import/', views.iam_user_import_view),
|
||||||
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
||||||
@ -19,6 +20,10 @@ urlpatterns = [
|
|||||||
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
||||||
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
||||||
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
||||||
|
path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view),
|
||||||
|
path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view),
|
||||||
|
path('iam-users/<int:pk>/allocate/', views.quota_allocate_view),
|
||||||
|
path('iam-users/<int:pk>/quota-history/', views.quota_history_view),
|
||||||
|
|
||||||
# Billing
|
# Billing
|
||||||
path('billing/overview/', views.spending_overview_view),
|
path('billing/overview/', views.spending_overview_view),
|
||||||
@ -30,4 +35,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Alerts
|
# Alerts
|
||||||
path('alerts/', views.alert_list_view),
|
path('alerts/', views.alert_list_view),
|
||||||
|
|
||||||
|
# Projects
|
||||||
|
path('projects/', views.project_list_view),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -7,19 +7,18 @@ from decimal import Decimal
|
|||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from utils.crypto import encrypt, decrypt, make_hint
|
from utils.crypto import encrypt, decrypt, make_hint
|
||||||
from utils.iam_service import IAMService
|
from utils.iam_service import IAMService, ProjectService
|
||||||
from utils.billing_service import BillingService
|
from utils.billing_service import BillingService
|
||||||
from utils.volcengine_client import VolcengineAPIError
|
from utils.volcengine_client import VolcengineAPIError
|
||||||
|
|
||||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
VolcAccountSerializer, VolcAccountCreateSerializer,
|
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||||
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||||
IAMUserThresholdSerializer,
|
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||||
GlobalConfigSerializer,
|
GlobalConfigSerializer,
|
||||||
AlertRecordSerializer,
|
AlertRecordSerializer,
|
||||||
DashboardSerializer,
|
DashboardSerializer,
|
||||||
@ -50,7 +49,7 @@ def dashboard_view(request):
|
|||||||
disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count()
|
disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count()
|
||||||
monitored = IAMUser.objects.filter(monitor_enabled=True).count()
|
monitored = IAMUser.objects.filter(monitor_enabled=True).count()
|
||||||
total_spending = IAMUser.objects.aggregate(
|
total_spending = IAMUser.objects.aggregate(
|
||||||
total=Sum('current_month_spending'))['total'] or Decimal('0')
|
total=Sum('consumed_total'))['total'] or Decimal('0')
|
||||||
recent_alerts = AlertRecord.objects.all()[:10]
|
recent_alerts = AlertRecord.objects.all()[:10]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -210,6 +209,88 @@ def iam_user_sync_view(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_create_view(request):
|
||||||
|
"""在火山引擎创建新的 IAM 子账号并纳入管理"""
|
||||||
|
serializer = IAMUserCreateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
d = serializer.validated_data
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account()
|
||||||
|
if not account:
|
||||||
|
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
|
||||||
|
# 1. Create user on Volcengine
|
||||||
|
try:
|
||||||
|
resp = svc.create_user(
|
||||||
|
username=d['username'],
|
||||||
|
display_name=d.get('display_name', ''),
|
||||||
|
email=d.get('email', ''),
|
||||||
|
phone=d.get('phone', ''),
|
||||||
|
)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'create_failed', 'message': f'创建用户失败: {e}'},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
volc_user = resp.get("Result", {}).get("User", {})
|
||||||
|
result_info = {'username': d['username']}
|
||||||
|
|
||||||
|
# 2. Create login profile if password provided
|
||||||
|
password = d.get('password', '')
|
||||||
|
if password:
|
||||||
|
try:
|
||||||
|
svc.create_login_profile(d['username'], password)
|
||||||
|
result_info['login_enabled'] = True
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
result_info['login_error'] = str(e)
|
||||||
|
|
||||||
|
# 3. Create access key
|
||||||
|
try:
|
||||||
|
ak_resp = svc.create_access_key(d['username'])
|
||||||
|
ak_data = ak_resp.get("Result", {}).get("AccessKey", {})
|
||||||
|
result_info['access_key_id'] = ak_data.get("AccessKeyId", "")
|
||||||
|
result_info['secret_access_key'] = ak_data.get("SecretAccessKey", "")
|
||||||
|
result_info['secret_key_warning'] = "SecretAccessKey 仅此一次显示,请立即保存!"
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
result_info['access_key_error'] = str(e)
|
||||||
|
|
||||||
|
# 4. Attach basic policies
|
||||||
|
for policy in ['AccessKeySelfManageAccess']:
|
||||||
|
try:
|
||||||
|
svc.attach_user_policy(d['username'], policy, 'System')
|
||||||
|
except VolcengineAPIError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Save to local DB
|
||||||
|
obj = IAMUser.objects.create(
|
||||||
|
volc_account=account,
|
||||||
|
username=d['username'],
|
||||||
|
display_name=d.get('display_name', ''),
|
||||||
|
user_id=volc_user.get("UserId", ""),
|
||||||
|
email=d.get('email', ''),
|
||||||
|
phone=d.get('phone', ''),
|
||||||
|
project_name=d.get('project_name', ''),
|
||||||
|
status=IAMUser.Status.ACTIVE,
|
||||||
|
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=obj,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"创建子账号 {d['username']}",
|
||||||
|
content=f"操作人: {request.user.username}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': f"子账号 {d['username']} 创建成功",
|
||||||
|
'user': IAMUserSerializer(obj).data,
|
||||||
|
'volcengine': result_info,
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def iam_user_import_view(request):
|
def iam_user_import_view(request):
|
||||||
"""导入指定的已有 IAM 用户"""
|
"""导入指定的已有 IAM 用户"""
|
||||||
@ -263,7 +344,7 @@ def iam_user_update_view(request, pk):
|
|||||||
except IAMUser.DoesNotExist:
|
except IAMUser.DoesNotExist:
|
||||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
serializer = IAMUserThresholdSerializer(data=request.data, partial=True)
|
serializer = IAMUserConfigSerializer(data=request.data, partial=True)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
for field, value in serializer.validated_data.items():
|
for field, value in serializer.validated_data.items():
|
||||||
@ -352,13 +433,147 @@ def iam_user_policies_view(request, pk):
|
|||||||
status=status.HTTP_502_BAD_GATEWAY)
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_attach_policy_view(request, pk):
|
||||||
|
"""给子账号附加权限策略"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
policy_name = request.data.get('policy_name', '')
|
||||||
|
policy_type = request.data.get('policy_type', 'System')
|
||||||
|
if not policy_name:
|
||||||
|
return Response({'error': 'missing_policy_name', 'message': '请指定策略名'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
svc.attach_user_policy(user.username, policy_name, policy_type)
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"附加策略 {policy_name} → {user.username}",
|
||||||
|
content=f"操作人: {request.user.username},策略类型: {policy_type}",
|
||||||
|
)
|
||||||
|
return Response({'message': f'已附加策略 {policy_name}'})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def iam_user_detach_policy_view(request, pk):
|
||||||
|
"""从子账号移除权限策略"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
policy_name = request.data.get('policy_name', '')
|
||||||
|
policy_type = request.data.get('policy_type', 'System')
|
||||||
|
if not policy_name:
|
||||||
|
return Response({'error': 'missing_policy_name', 'message': '请指定策略名'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
svc = IAMService(ak, sk)
|
||||||
|
try:
|
||||||
|
svc.detach_user_policy(user.username, policy_name, policy_type)
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"移除策略 {policy_name} ← {user.username}",
|
||||||
|
content=f"操作人: {request.user.username},策略类型: {policy_type}",
|
||||||
|
)
|
||||||
|
return Response({'message': f'已移除策略 {policy_name}'})
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Quota Allocation ====================
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def quota_allocate_view(request, pk):
|
||||||
|
"""额度变更:正数=追加,负数=扣减"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = QuotaAllocateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
amount = serializer.validated_data['amount']
|
||||||
|
note = serializer.validated_data.get('note', '')
|
||||||
|
|
||||||
|
new_total = user.allocated_quota + amount
|
||||||
|
|
||||||
|
# 扣减保护:总额度不能低于已消费金额
|
||||||
|
if new_total < user.consumed_total:
|
||||||
|
return Response({
|
||||||
|
'error': 'quota_underflow',
|
||||||
|
'message': (
|
||||||
|
f'扣减后总额度 ¥{new_total:.2f} 低于已消费 ¥{user.consumed_total:.2f},'
|
||||||
|
f'最多可扣减 ¥{user.allocated_quota - user.consumed_total:.2f}'
|
||||||
|
),
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user.allocated_quota = new_total
|
||||||
|
user.triggered_alerts = [] # 额度变更时重置告警状态
|
||||||
|
user.save(update_fields=['allocated_quota', 'triggered_alerts'])
|
||||||
|
|
||||||
|
is_deduct = amount < 0
|
||||||
|
action_label = f"扣减额度 ¥{abs(amount)}" if is_deduct else f"追加额度 ¥{amount}"
|
||||||
|
|
||||||
|
allocation = QuotaAllocation.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
amount=amount,
|
||||||
|
total_after=user.allocated_quota,
|
||||||
|
note=note,
|
||||||
|
created_by=request.user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.MANUAL,
|
||||||
|
title=f"{action_label} → {user.username}",
|
||||||
|
content=f"操作人: {request.user.username},变更后总额度: ¥{user.allocated_quota},备注: {note}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': f'{action_label},当前总额度 ¥{user.allocated_quota}',
|
||||||
|
'user': IAMUserSerializer(user).data,
|
||||||
|
'allocation': QuotaAllocationSerializer(allocation).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def quota_history_view(request, pk):
|
||||||
|
"""查看子账号的额度划拨历史"""
|
||||||
|
try:
|
||||||
|
user = IAMUser.objects.get(pk=pk)
|
||||||
|
except IAMUser.DoesNotExist:
|
||||||
|
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
allocations = QuotaAllocation.objects.filter(iam_user=user)
|
||||||
|
return Response(QuotaAllocationSerializer(allocations, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Billing ====================
|
# ==================== Billing ====================
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def spending_overview_view(request):
|
def spending_overview_view(request):
|
||||||
"""消费总览"""
|
"""消费总览"""
|
||||||
bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m"))
|
bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m"))
|
||||||
users = IAMUser.objects.all().order_by('-current_month_spending')
|
users = IAMUser.objects.all().order_by('-consumed_total')
|
||||||
return Response({
|
return Response({
|
||||||
'period': bill_period,
|
'period': bill_period,
|
||||||
'users': IAMUserSerializer(users, many=True).data,
|
'users': IAMUserSerializer(users, many=True).data,
|
||||||
@ -417,3 +632,29 @@ def alert_list_view(request):
|
|||||||
alerts = alerts.filter(alert_type=alert_type)
|
alerts = alerts.filter(alert_type=alert_type)
|
||||||
limit = int(request.query_params.get('limit', 50))
|
limit = int(request.query_params.get('limit', 50))
|
||||||
return Response(AlertRecordSerializer(alerts[:limit], many=True).data)
|
return Response(AlertRecordSerializer(alerts[:limit], many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Projects ====================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def project_list_view(request):
|
||||||
|
"""从火山引擎拉取项目列表"""
|
||||||
|
account, ak, sk = _get_volc_account()
|
||||||
|
if not ak:
|
||||||
|
return Response({'error': 'no_account', 'message': '请先配置火山主账号'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
svc = ProjectService(ak, sk)
|
||||||
|
projects = svc.list_projects()
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
'name': p.get('ProjectName', ''),
|
||||||
|
'display_name': p.get('DisplayName', p.get('ProjectName', '')),
|
||||||
|
'description': p.get('Description', ''),
|
||||||
|
}
|
||||||
|
for p in projects
|
||||||
|
]
|
||||||
|
return Response(result)
|
||||||
|
except VolcengineAPIError as e:
|
||||||
|
return Response({'error': 'api_error', 'message': str(e)},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|||||||
@ -5,8 +5,13 @@ from datetime import timedelta
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# --- Core ---
|
# --- Core ---
|
||||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key-change-in-production')
|
|
||||||
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||||
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
|
||||||
|
if not SECRET_KEY:
|
||||||
|
if DEBUG:
|
||||||
|
SECRET_KEY = 'dev-insecure-key-for-local-development-only'
|
||||||
|
else:
|
||||||
|
raise ValueError("DJANGO_SECRET_KEY must be set in production (DEBUG=False)")
|
||||||
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',')
|
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',')
|
||||||
|
|
||||||
# --- Apps ---
|
# --- Apps ---
|
||||||
@ -73,10 +78,13 @@ if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
# Docker uses /app/data/ volume; local dev uses backend/db.sqlite3
|
||||||
|
db_dir = Path(os.environ.get('DB_DIR', BASE_DIR))
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': db_dir / 'db.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +99,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
'apps.monitor.permissions.APIKeyAuthentication',
|
||||||
],
|
],
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
@ -130,10 +139,6 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
# --- Volcengine ---
|
|
||||||
VOLC_ACCESS_KEY = os.environ.get('VOLC_ACCESS_KEY', '')
|
|
||||||
VOLC_SECRET_KEY = os.environ.get('VOLC_SECRET_KEY', '')
|
|
||||||
|
|
||||||
# --- Encryption ---
|
# --- Encryption ---
|
||||||
AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '')
|
AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '')
|
||||||
|
|
||||||
@ -143,5 +148,3 @@ FEISHU_WEBHOOK_URL = os.environ.get('FEISHU_WEBHOOK_URL', '')
|
|||||||
# --- API Key (for external systems like AirDrama) ---
|
# --- API Key (for external systems like AirDrama) ---
|
||||||
AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '')
|
AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '')
|
||||||
|
|
||||||
# --- Monitor ---
|
|
||||||
MONITOR_INTERVAL = int(os.environ.get('MONITOR_INTERVAL', '3600'))
|
|
||||||
|
|||||||
19
backend/entrypoint.sh
Normal file
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 子账号管理服务"""
|
"""IAM 子账号管理服务"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from .volcengine_client import get_iam_client, VolcengineAPIError
|
from .volcengine_client import get_iam_client, get_resource_client, VolcengineAPIError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -118,3 +118,29 @@ class IAMService:
|
|||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors))
|
raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectService:
|
||||||
|
"""封装火山引擎项目管理 API"""
|
||||||
|
|
||||||
|
def __init__(self, ak: str, sk: str):
|
||||||
|
self.client = get_resource_client(ak, sk)
|
||||||
|
|
||||||
|
def list_projects(self) -> list:
|
||||||
|
"""获取所有项目列表"""
|
||||||
|
projects = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
resp = self.client.call("ListProjects", {
|
||||||
|
"PageNumber": str(page),
|
||||||
|
"PageSize": "50",
|
||||||
|
})
|
||||||
|
items = resp.get("Result", {}).get("Projects", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
projects.extend(items)
|
||||||
|
total = resp.get("Result", {}).get("Total", 0)
|
||||||
|
if len(projects) >= total:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return projects
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"""定时消费监控任务"""
|
"""定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -10,15 +10,15 @@ _scheduler_started = False
|
|||||||
|
|
||||||
|
|
||||||
def check_spending():
|
def check_spending():
|
||||||
"""定时检查所有子账号消费"""
|
"""定时检查所有子账号消费,对比已划拨额度触发阶梯告警"""
|
||||||
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord
|
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
||||||
from utils.crypto import decrypt
|
from utils.crypto import decrypt
|
||||||
from utils.billing_service import BillingService
|
from utils.billing_service import BillingService
|
||||||
from utils.iam_service import IAMService
|
from utils.iam_service import IAMService
|
||||||
from utils.feishu import send_feishu_alert
|
from utils.feishu import send_feishu_alert
|
||||||
|
|
||||||
bill_period = datetime.now().strftime("%Y-%m")
|
|
||||||
config = GlobalConfig.get_solo()
|
config = GlobalConfig.get_solo()
|
||||||
|
webhook = config.feishu_webhook_url
|
||||||
|
|
||||||
for volc_account in VolcAccount.objects.filter(is_active=True):
|
for volc_account in VolcAccount.objects.filter(is_active=True):
|
||||||
ak = decrypt(volc_account.access_key_enc)
|
ak = decrypt(volc_account.access_key_enc)
|
||||||
@ -37,87 +37,105 @@ def check_spending():
|
|||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
|
# 查询当月消费(按项目筛选)
|
||||||
|
bill_period = timezone.now().strftime("%Y-%m")
|
||||||
spending = billing.get_spending_by_project(
|
spending = billing.get_spending_by_project(
|
||||||
bill_period, user.project_name or None
|
bill_period, user.project_name or None
|
||||||
)
|
)
|
||||||
user.current_month_spending = spending
|
|
||||||
user.spending_updated_at = datetime.now()
|
|
||||||
user.save(update_fields=['current_month_spending', 'spending_updated_at'])
|
|
||||||
|
|
||||||
disable_threshold = user.get_disable_threshold()
|
# 记录月度快照
|
||||||
alert_threshold = user.get_alert_threshold()
|
SpendingRecord.objects.update_or_create(
|
||||||
|
iam_user=user, bill_period=bill_period,
|
||||||
|
defaults={'amount': spending},
|
||||||
|
)
|
||||||
|
|
||||||
# Check disable threshold
|
# 累计消费 = 所有月份的消费之和
|
||||||
if (user.auto_disable_enabled
|
from django.db.models import Sum
|
||||||
and disable_threshold
|
total = SpendingRecord.objects.filter(
|
||||||
and spending >= disable_threshold):
|
iam_user=user
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||||
|
|
||||||
already_disabled = AlertRecord.objects.filter(
|
user.consumed_total = total
|
||||||
iam_user=user,
|
user.spending_updated_at = timezone.now()
|
||||||
alert_type=AlertRecord.AlertType.DISABLE,
|
|
||||||
created_at__month=datetime.now().month,
|
|
||||||
created_at__year=datetime.now().year,
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
if not already_disabled:
|
quota = user.allocated_quota
|
||||||
try:
|
if not quota or quota <= 0:
|
||||||
iam_svc.disable_user(user.username)
|
user.save(update_fields=['consumed_total', 'spending_updated_at'])
|
||||||
user.status = IAMUser.Status.DISABLED
|
continue
|
||||||
user.save(update_fields=['status'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"停用用户 {user.username} 失败: {e}")
|
|
||||||
|
|
||||||
alert = AlertRecord.objects.create(
|
usage_percent = float(total) / float(quota) * 100
|
||||||
iam_user=user,
|
triggered = user.triggered_alerts or []
|
||||||
alert_type=AlertRecord.AlertType.DISABLE,
|
|
||||||
title=f"子账号 {user.username} 已自动停用",
|
|
||||||
content=f"本月消费 ¥{spending:.2f},达到停用阈值 ¥{disable_threshold:.2f}",
|
|
||||||
spending_amount=spending,
|
|
||||||
threshold_amount=disable_threshold,
|
|
||||||
)
|
|
||||||
webhook = config.feishu_webhook_url
|
|
||||||
send_feishu_alert(
|
|
||||||
webhook,
|
|
||||||
"🚨 子账号已自动停用",
|
|
||||||
f"**用户**: {user.username}\n"
|
|
||||||
f"**消费**: ¥{spending:.2f}\n"
|
|
||||||
f"**阈值**: ¥{disable_threshold:.2f}\n"
|
|
||||||
f"如需恢复,请在 AirGate 管理后台操作。",
|
|
||||||
template="red",
|
|
||||||
)
|
|
||||||
alert.notified = True
|
|
||||||
alert.save(update_fields=['notified'])
|
|
||||||
|
|
||||||
# Check alert threshold
|
# --- 阶梯式告警 ---
|
||||||
elif alert_threshold and spending >= alert_threshold:
|
for step in user.get_alert_thresholds():
|
||||||
already_alerted = AlertRecord.objects.filter(
|
if usage_percent >= step and step not in triggered:
|
||||||
iam_user=user,
|
triggered.append(step)
|
||||||
alert_type=AlertRecord.AlertType.WARNING,
|
threshold_amount = Decimal(str(quota)) * step / 100
|
||||||
created_at__month=datetime.now().month,
|
|
||||||
created_at__year=datetime.now().year,
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
if not already_alerted:
|
AlertRecord.objects.create(
|
||||||
alert = AlertRecord.objects.create(
|
|
||||||
iam_user=user,
|
iam_user=user,
|
||||||
alert_type=AlertRecord.AlertType.WARNING,
|
alert_type=AlertRecord.AlertType.WARNING,
|
||||||
title=f"子账号 {user.username} 消费告警",
|
title=f"{user.username} 消费达到额度 {step}%",
|
||||||
content=f"本月消费 ¥{spending:.2f},达到告警阈值 ¥{alert_threshold:.2f}",
|
content=(
|
||||||
spending_amount=spending,
|
f"累计消费 ¥{total:.2f},"
|
||||||
threshold_amount=alert_threshold,
|
f"已划拨额度 ¥{quota:.2f} 的 {step}%\n"
|
||||||
|
f"剩余额度: ¥{user.remaining_quota:.2f}"
|
||||||
|
),
|
||||||
|
spending_amount=total,
|
||||||
|
threshold_amount=threshold_amount,
|
||||||
|
notified=True,
|
||||||
)
|
)
|
||||||
webhook = config.feishu_webhook_url
|
|
||||||
send_feishu_alert(
|
send_feishu_alert(
|
||||||
webhook,
|
webhook,
|
||||||
"⚠️ 子账号消费告警",
|
f"⚠️ {user.username} 消费达到额度 {step}%",
|
||||||
f"**用户**: {user.username}\n"
|
f"**用户**: {user.username}\n"
|
||||||
f"**消费**: ¥{spending:.2f}\n"
|
f"**累计消费**: ¥{total:.2f}\n"
|
||||||
f"**告警阈值**: ¥{alert_threshold:.2f}\n"
|
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||||
f"**停用阈值**: ¥{disable_threshold:.2f}",
|
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
|
||||||
template="orange",
|
f"**使用率**: {usage_percent:.1f}%",
|
||||||
|
template="orange" if step < 90 else "red",
|
||||||
)
|
)
|
||||||
alert.notified = True
|
|
||||||
alert.save(update_fields=['notified'])
|
# --- 额度用尽,自动停用 ---
|
||||||
|
if (usage_percent >= 100
|
||||||
|
and user.auto_disable_enabled
|
||||||
|
and 100 not in triggered):
|
||||||
|
triggered.append(100)
|
||||||
|
|
||||||
|
try:
|
||||||
|
iam_svc.disable_user(user.username)
|
||||||
|
user.status = IAMUser.Status.DISABLED
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停用用户 {user.username} 失败: {e}")
|
||||||
|
|
||||||
|
AlertRecord.objects.create(
|
||||||
|
iam_user=user,
|
||||||
|
alert_type=AlertRecord.AlertType.DISABLE,
|
||||||
|
title=f"{user.username} 额度用尽,已自动停用",
|
||||||
|
content=(
|
||||||
|
f"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
|
||||||
|
f"如需继续使用,请划拨新额度后恢复账号。"
|
||||||
|
),
|
||||||
|
spending_amount=total,
|
||||||
|
threshold_amount=quota,
|
||||||
|
notified=True,
|
||||||
|
)
|
||||||
|
send_feishu_alert(
|
||||||
|
webhook,
|
||||||
|
f"🚨 {user.username} 额度用尽,已自动停用",
|
||||||
|
f"**用户**: {user.username}\n"
|
||||||
|
f"**累计消费**: ¥{total:.2f}\n"
|
||||||
|
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||||
|
f"额度已用尽,账号已自动停用。\n"
|
||||||
|
f"请在 AirGate 划拨新额度后恢复。",
|
||||||
|
template="red",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.triggered_alerts = triggered
|
||||||
|
user.save(update_fields=[
|
||||||
|
'consumed_total', 'spending_updated_at',
|
||||||
|
'triggered_alerts', 'status',
|
||||||
|
])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"检查用户 {user.username} 消费失败: {e}")
|
logger.error(f"检查用户 {user.username} 消费失败: {e}")
|
||||||
@ -132,10 +150,11 @@ def start_scheduler():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from django.conf import settings
|
from apps.monitor.models import GlobalConfig
|
||||||
|
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
interval = getattr(settings, 'MONITOR_INTERVAL', 3600)
|
config = GlobalConfig.get_solo()
|
||||||
|
interval = config.monitor_interval_seconds or 3600
|
||||||
scheduler.add_job(check_spending, 'interval', seconds=interval,
|
scheduler.add_job(check_spending, 'interval', seconds=interval,
|
||||||
id='check_spending', replace_existing=True)
|
id='check_spending', replace_existing=True)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|||||||
@ -113,3 +113,8 @@ def get_iam_client(ak: str, sk: str) -> VolcengineClient:
|
|||||||
def get_billing_client(ak: str, sk: str) -> VolcengineClient:
|
def get_billing_client(ak: str, sk: str) -> VolcengineClient:
|
||||||
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
|
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
|
||||||
version="2022-01-01")
|
version="2022-01-01")
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource_client(ak: str, sk: str) -> VolcengineClient:
|
||||||
|
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
|
||||||
|
version="2021-08-01")
|
||||||
|
|||||||
27
docker-compose.yml
Normal file
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'
|
import router from '../router'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8100',
|
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
<el-row :gutter="20" style="margin-bottom: 20px;" v-if="balance">
|
<el-row :gutter="20" style="margin-bottom: 20px;" v-if="balance">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || 0)"
|
<el-statistic title="主账号可用余额" :value="Number(balance.AvailableBalance || balance.availableBalance || 0)"
|
||||||
:precision="2" prefix="¥" />
|
:precision="2" prefix="¥" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -21,33 +21,48 @@
|
|||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>各子账号本月消费</span>
|
<span>各子账号消费与额度</span>
|
||||||
</template>
|
</template>
|
||||||
<el-table :data="overview.users || []" stripe v-loading="loading" style="width:100%;"
|
<el-table :data="overview.users || []" stripe v-loading="loading" style="width:100%;"
|
||||||
:default-sort="{ prop: 'current_month_spending', order: 'descending' }">
|
:default-sort="{ prop: 'consumed_total', order: 'descending' }">
|
||||||
<el-table-column prop="username" label="用户名" width="160" />
|
<el-table-column prop="username" label="用户名" width="160" />
|
||||||
<el-table-column prop="display_name" label="显示名" width="140" />
|
<el-table-column prop="display_name" label="显示名" width="140" />
|
||||||
<el-table-column prop="project_name" label="项目" width="160" />
|
<el-table-column prop="project_name" label="项目" width="160" />
|
||||||
<el-table-column prop="current_month_spending" label="本月消费" width="140" sortable>
|
<el-table-column prop="consumed_total" label="累计消费" width="140" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span style="font-weight: 600; color: #e6a23c;">
|
<span style="font-weight: 600; color: #e6a23c;">
|
||||||
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
¥{{ Number(row.consumed_total).toLocaleString() }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="告警阈值" width="120">
|
<el-table-column label="已划拨额度" width="120">
|
||||||
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template>
|
<template #default="{ row }">¥{{ Number(row.allocated_quota).toLocaleString() }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="停用阈值" width="120">
|
<el-table-column label="剩余额度" width="120">
|
||||||
<template #default="{ row }">¥{{ row.effective_disable_threshold }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="消费进度" min-width="200">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-progress
|
<span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
|
||||||
:percentage="Math.min(100, (Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100))"
|
¥{{ Number(row.remaining_quota).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="额度使用率" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress v-if="Number(row.allocated_quota) > 0"
|
||||||
|
:percentage="Math.min(100, row.usage_percent || 0)"
|
||||||
:color="progressColor(row)"
|
:color="progressColor(row)"
|
||||||
:stroke-width="12"
|
:stroke-width="12"
|
||||||
|
:format="() => `${row.usage_percent || 0}%`"
|
||||||
/>
|
/>
|
||||||
|
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="告警阶梯" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
|
||||||
|
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
|
||||||
|
size="small" style="margin:1px 2px;">
|
||||||
|
{{ step }}%
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="spending_updated_at" label="更新时间" width="180">
|
<el-table-column prop="spending_updated_at" label="更新时间" width="180">
|
||||||
@ -105,8 +120,8 @@ async function loadBalance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function progressColor(row) {
|
function progressColor(row) {
|
||||||
const pct = Number(row.current_month_spending) / Number(row.effective_disable_threshold) * 100
|
const pct = row.usage_percent || 0
|
||||||
if (pct >= 80) return '#f56c6c'
|
if (pct >= 90) return '#f56c6c'
|
||||||
if (pct >= 50) return '#e6a23c'
|
if (pct >= 50) return '#e6a23c'
|
||||||
return '#67c23a'
|
return '#67c23a'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<el-statistic title="本月总消费" :value="Number(data.total_spending)" :precision="2"
|
<el-statistic title="累计总消费" :value="Number(data.total_spending)" :precision="2"
|
||||||
prefix="¥" />
|
prefix="¥" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|||||||
@ -2,72 +2,150 @@
|
|||||||
<div>
|
<div>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
<h2>子账号管理</h2>
|
<h2>子账号管理</h2>
|
||||||
<div>
|
<div style="display:flex; gap:8px;">
|
||||||
|
<el-button type="success" @click="showCreate = true">
|
||||||
|
<el-icon><Plus /></el-icon> 创建子账号
|
||||||
|
</el-button>
|
||||||
<el-button type="primary" @click="handleSync" :loading="syncing">
|
<el-button type="primary" @click="handleSync" :loading="syncing">
|
||||||
<el-icon><Refresh /></el-icon> 同步火山用户
|
<el-icon><Refresh /></el-icon> 同步已有用户
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%;">
|
<el-table :data="users" stripe v-loading="loading" style="width: 100%;">
|
||||||
<el-table-column prop="username" label="用户名" width="160" />
|
<el-table-column prop="username" label="用户名" width="140" />
|
||||||
<el-table-column prop="display_name" label="显示名" width="140" />
|
<el-table-column prop="display_name" label="显示名" width="110" />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small">
|
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small">
|
||||||
{{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }}
|
{{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="project_name" label="关联项目" width="140" />
|
<el-table-column label="已划拨额度" width="120">
|
||||||
<el-table-column label="本月消费" width="120">
|
<template #default="{ row }">¥{{ Number(row.allocated_quota).toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="累计消费" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :style="{ color: Number(row.current_month_spending) > 0 ? '#e6a23c' : '' }">
|
<span :style="{ color: Number(row.consumed_total) > 0 ? '#e6a23c' : '' }">
|
||||||
¥{{ Number(row.current_month_spending).toFixed(2) }}
|
¥{{ Number(row.consumed_total).toLocaleString() }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="告警阈值" width="110">
|
<el-table-column label="剩余额度" width="120">
|
||||||
<template #default="{ row }">¥{{ row.effective_alert_threshold }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="停用阈值" width="110">
|
|
||||||
<template #default="{ row }">¥{{ row.effective_disable_threshold }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="监控" width="70">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.monitor_enabled ? 'success' : 'info'" size="small">
|
<span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
|
||||||
{{ row.monitor_enabled ? '开' : '关' }}
|
¥{{ Number(row.remaining_quota).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用率" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress v-if="Number(row.allocated_quota) > 0"
|
||||||
|
:percentage="Math.min(100, row.usage_percent || 0)"
|
||||||
|
:color="row.usage_percent >= 90 ? '#f56c6c' : row.usage_percent >= 50 ? '#e6a23c' : '#67c23a'"
|
||||||
|
:stroke-width="10"
|
||||||
|
:format="() => `${row.usage_percent || 0}%`"
|
||||||
|
/>
|
||||||
|
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="告警阶梯" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
|
||||||
|
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
|
||||||
|
size="small" style="margin:1px 2px;">
|
||||||
|
{{ step }}%
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right">
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="warning" @click="openAllocate(row)">划拨</el-button>
|
||||||
<el-button size="small" @click="openConfig(row)">配置</el-button>
|
<el-button size="small" @click="openConfig(row)">配置</el-button>
|
||||||
<el-button v-if="row.status === 'active'" size="small" type="danger" @click="handleDisable(row)">停用</el-button>
|
<el-button v-if="row.status === 'active'" size="small" type="danger" @click="handleDisable(row)">停用</el-button>
|
||||||
<el-button v-if="row.status === 'disabled'" size="small" type="success" @click="handleEnable(row)">恢复</el-button>
|
<el-button v-if="row.status === 'disabled'" size="small" type="success" @click="handleEnable(row)">恢复</el-button>
|
||||||
<el-button size="small" @click="viewPolicies(row)">权限</el-button>
|
<el-button size="small" @click="openPolicies(row)">权限</el-button>
|
||||||
|
<el-button size="small" @click="openQuotaHistory(row)">记录</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Allocate Dialog -->
|
||||||
|
<el-dialog v-model="allocateVisible" title="额度变更" width="480px">
|
||||||
|
<div style="margin-bottom:16px; padding:12px; background:#f5f7fa; border-radius:8px;">
|
||||||
|
<div>用户: <b>{{ allocateUser?.username }}</b></div>
|
||||||
|
<div>当前额度: ¥{{ Number(allocateUser?.allocated_quota || 0).toLocaleString() }}</div>
|
||||||
|
<div>已消费: ¥{{ Number(allocateUser?.consumed_total || 0).toLocaleString() }}</div>
|
||||||
|
<div>剩余: <span style="color:#67c23a;font-weight:600;">¥{{ Number(allocateUser?.remaining_quota || 0).toLocaleString() }}</span></div>
|
||||||
|
</div>
|
||||||
|
<el-form :model="allocateForm" label-width="100px">
|
||||||
|
<el-form-item label="操作类型">
|
||||||
|
<el-radio-group v-model="allocateForm.mode">
|
||||||
|
<el-radio-button value="add">追加额度</el-radio-button>
|
||||||
|
<el-radio-button value="deduct">扣减额度</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="allocateForm.mode === 'add' ? '追加金额(元)' : '扣减金额(元)'">
|
||||||
|
<el-input-number v-model="allocateForm.amount" :min="1" :step="10000"
|
||||||
|
:max="allocateForm.mode === 'deduct' ? maxDeduct : undefined"
|
||||||
|
:precision="2" style="width:100%;" controls-position="right" />
|
||||||
|
<div v-if="allocateForm.mode === 'deduct'" class="form-hint">
|
||||||
|
最多可扣减: ¥{{ maxDeduct.toLocaleString() }}(不能低于已消费金额)
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="allocateForm.note" :placeholder="allocateForm.mode === 'add' ? '如:3月额度追加' : '如:额度调整'" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div v-if="allocateForm.amount" style="padding:8px 12px; border-radius:4px; font-size:13px;"
|
||||||
|
:style="{ background: allocateForm.mode === 'add' ? '#f0f9eb' : '#fef0f0' }">
|
||||||
|
变更后总额度: ¥{{ newTotalAfter.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="allocateVisible = false">取消</el-button>
|
||||||
|
<el-button :type="allocateForm.mode === 'add' ? 'primary' : 'warning'"
|
||||||
|
@click="submitAllocate" :loading="allocating">
|
||||||
|
{{ allocateForm.mode === 'add' ? '确认追加' : '确认扣减' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Config Dialog -->
|
<!-- Config Dialog -->
|
||||||
<el-dialog v-model="configVisible" title="阈值配置" width="500px">
|
<el-dialog v-model="configVisible" title="监控配置" width="560px">
|
||||||
<el-form :model="configForm" label-width="120px">
|
<el-form :model="configForm" label-width="130px">
|
||||||
<el-form-item label="告警阈值(元)">
|
<el-form-item label="关联项目">
|
||||||
<el-input-number v-model="configForm.alert_threshold" :min="0" :precision="2" style="width:100%;" />
|
<el-select v-model="configForm.project_name" placeholder="选择火山引擎项目"
|
||||||
<div class="form-hint">留空则使用全局默认值</div>
|
filterable clearable style="width:100%;" :loading="projectsLoading">
|
||||||
|
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||||
|
</el-select>
|
||||||
|
<div class="form-hint">
|
||||||
|
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="停用阈值(元)">
|
|
||||||
<el-input-number v-model="configForm.disable_threshold" :min="0" :precision="2" style="width:100%;" />
|
<el-divider content-position="left">告警阶梯</el-divider>
|
||||||
<div class="form-hint">留空则使用全局默认值</div>
|
|
||||||
|
<el-form-item label="告警阶梯(%)">
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
<el-tag v-for="(step, i) in configForm.alert_thresholds" :key="i"
|
||||||
|
closable @close="removeStep(i)" size="large">{{ step }}%</el-tag>
|
||||||
|
<el-input-number v-model="newStep" :min="1" :max="99" size="small" style="width:100px;" />
|
||||||
|
<el-button size="small" @click="addStep">添加</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">达到已划拨额度对应百分比时发送告警</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">开关</el-divider>
|
||||||
|
|
||||||
<el-form-item label="消费监控">
|
<el-form-item label="消费监控">
|
||||||
<el-switch v-model="configForm.monitor_enabled" />
|
<el-switch v-model="configForm.monitor_enabled" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="自动停用">
|
<el-form-item label="额度用尽自动停用">
|
||||||
<el-switch v-model="configForm.auto_disable_enabled" />
|
<el-switch v-model="configForm.auto_disable_enabled" />
|
||||||
|
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -76,19 +154,109 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Policies Dialog -->
|
<!-- Quota History Dialog -->
|
||||||
<el-dialog v-model="policiesVisible" title="权限策略" width="600px">
|
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
|
||||||
<el-table :data="policies" stripe v-loading="policiesLoading">
|
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
|
||||||
<el-table-column prop="PolicyName" label="策略名" />
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
<el-table-column prop="PolicyType" label="类型" width="100" />
|
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||||
<el-table-column prop="Description" label="说明" />
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" label="变更金额" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: Number(row.amount) >= 0 ? '#67c23a' : '#f56c6c' }">
|
||||||
|
{{ Number(row.amount) >= 0 ? '+' : '' }}¥{{ Number(row.amount).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_after" label="划拨后总额度" width="130">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.total_after).toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" />
|
||||||
|
<el-table-column prop="created_by" label="操作人" width="100" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Policies Dialog -->
|
||||||
|
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="650px">
|
||||||
|
<div style="margin-bottom:12px; display:flex; gap:8px;">
|
||||||
|
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
|
||||||
|
<el-option-group label="常用策略">
|
||||||
|
<el-option value="ArkFullAccess" label="ArkFullAccess(方舟/Seedance 完整权限)" />
|
||||||
|
<el-option value="ArkReadOnlyAccess" label="ArkReadOnlyAccess(方舟只读)" />
|
||||||
|
<el-option value="TOSFullAccess" label="TOSFullAccess(对象存储完整权限)" />
|
||||||
|
<el-option value="TOSReadOnlyAccess" label="TOSReadOnlyAccess(对象存储只读)" />
|
||||||
|
<el-option value="AccessKeySelfManageAccess" label="AccessKeySelfManageAccess(自管理密钥)" />
|
||||||
|
</el-option-group>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleAttachPolicy" :disabled="!policyToAttach">附加</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="policies" stripe v-loading="policiesLoading" empty-text="暂无策略">
|
||||||
|
<el-table-column prop="PolicyName" label="策略名" />
|
||||||
|
<el-table-column prop="PolicyType" label="类型" width="80" />
|
||||||
|
<el-table-column prop="Description" label="说明" />
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" text @click="handleDetachPolicy(row)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Create User Dialog -->
|
||||||
|
<el-dialog v-model="showCreate" title="创建子账号" width="520px">
|
||||||
|
<el-alert type="warning" :closable="false" style="margin-bottom:16px;"
|
||||||
|
description="创建后会在火山引擎生成 IAM 用户和 API 密钥。SecretKey 仅显示一次,请务必保存!" />
|
||||||
|
<el-form :model="createForm" label-width="110px">
|
||||||
|
<el-form-item label="用户名" required>
|
||||||
|
<el-input v-model="createForm.username" placeholder="英文字母开头,如 dept_video" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="显示名">
|
||||||
|
<el-input v-model="createForm.display_name" placeholder="如:视频部门" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱">
|
||||||
|
<el-input v-model="createForm.email" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号">
|
||||||
|
<el-input v-model="createForm.phone" placeholder="选填,如 +8618000000000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="控制台密码">
|
||||||
|
<el-input v-model="createForm.password" type="password" show-password
|
||||||
|
placeholder="选填,填了才开通控制台登录" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关联项目">
|
||||||
|
<el-select v-model="createForm.project_name" placeholder="选填" filterable clearable
|
||||||
|
style="width:100%;" :loading="projectsLoading">
|
||||||
|
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreate" :loading="creating">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Secret Key Display Dialog -->
|
||||||
|
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="520px" :close-on-click-modal="false">
|
||||||
|
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
|
||||||
|
description="SecretAccessKey 仅此一次显示!关闭后无法再次获取,请立即复制保存。" />
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="AccessKey ID">
|
||||||
|
<code>{{ createdKeys.access_key_id }}</code>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SecretAccessKey">
|
||||||
|
<code style="word-break:break-all;">{{ createdKeys.secret_access_key }}</code>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="copyKeys">复制到剪贴板</el-button>
|
||||||
|
<el-button @click="showSecretKey = false">已保存,关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
@ -96,14 +264,35 @@ const users = ref([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const createForm = ref({ username: '', display_name: '', email: '', phone: '', password: '', project_name: '' })
|
||||||
|
const showSecretKey = ref(false)
|
||||||
|
const createdKeys = ref({ access_key_id: '', secret_access_key: '' })
|
||||||
|
|
||||||
|
// Allocate
|
||||||
|
const allocateVisible = ref(false)
|
||||||
|
const allocateUser = ref(null)
|
||||||
|
const allocateForm = ref({ amount: null, note: '' })
|
||||||
|
const allocating = ref(false)
|
||||||
|
|
||||||
|
// Config
|
||||||
const configVisible = ref(false)
|
const configVisible = ref(false)
|
||||||
const configForm = ref({})
|
const configForm = ref({})
|
||||||
const configUserId = ref(null)
|
const configUserId = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const newStep = ref(null)
|
||||||
|
|
||||||
const policiesVisible = ref(false)
|
// Projects
|
||||||
const policies = ref([])
|
const projects = ref([])
|
||||||
const policiesLoading = ref(false)
|
const projectsLoading = ref(false)
|
||||||
|
|
||||||
|
// Quota History
|
||||||
|
const historyVisible = ref(false)
|
||||||
|
const historyUser = ref(null)
|
||||||
|
const quotaHistory = ref([])
|
||||||
|
const historyLoading = ref(false)
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -131,7 +320,9 @@ async function handleSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDisable(row) {
|
async function handleDisable(row) {
|
||||||
await ElMessageBox.confirm(`确定要停用子账号 "${row.username}" 吗?停用后该账号的控制台和 API 访问都将被禁止。`, '确认停用', { type: 'warning' })
|
await ElMessageBox.confirm(
|
||||||
|
`确定要停用子账号 "${row.username}" 吗?`, '确认停用', { type: 'warning' }
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/v1/iam-users/${row.id}/disable/`)
|
await api.post(`/api/v1/iam-users/${row.id}/disable/`)
|
||||||
ElMessage.success('已停用')
|
ElMessage.success('已停用')
|
||||||
@ -152,15 +343,97 @@ async function handleEnable(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Policies
|
||||||
|
const policiesVisible = ref(false)
|
||||||
|
const policiesUser = ref(null)
|
||||||
|
const policies = ref([])
|
||||||
|
const policiesLoading = ref(false)
|
||||||
|
const policyToAttach = ref('')
|
||||||
|
|
||||||
|
// --- Allocate ---
|
||||||
|
const maxDeduct = computed(() => {
|
||||||
|
if (!allocateUser.value) return 0
|
||||||
|
return Math.max(0, Number(allocateUser.value.allocated_quota || 0) - Number(allocateUser.value.consumed_total || 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
const newTotalAfter = computed(() => {
|
||||||
|
const current = Number(allocateUser.value?.allocated_quota || 0)
|
||||||
|
const amt = Number(allocateForm.value.amount || 0)
|
||||||
|
return allocateForm.value.mode === 'add' ? current + amt : current - amt
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAllocate(row) {
|
||||||
|
allocateUser.value = row
|
||||||
|
allocateForm.value = { mode: 'add', amount: null, note: '' }
|
||||||
|
allocateVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAllocate() {
|
||||||
|
if (!allocateForm.value.amount || allocateForm.value.amount <= 0) {
|
||||||
|
ElMessage.warning('请输入金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allocating.value = true
|
||||||
|
const actualAmount = allocateForm.value.mode === 'deduct'
|
||||||
|
? -allocateForm.value.amount
|
||||||
|
: allocateForm.value.amount
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(
|
||||||
|
`/api/v1/iam-users/${allocateUser.value.id}/allocate/`,
|
||||||
|
{ amount: actualAmount, note: allocateForm.value.note }
|
||||||
|
)
|
||||||
|
ElMessage.success(data.message)
|
||||||
|
allocateVisible.value = false
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
allocating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
async function loadProjects() {
|
||||||
|
projectsLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/v1/projects/')
|
||||||
|
projects.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '获取项目列表失败')
|
||||||
|
} finally {
|
||||||
|
projectsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openConfig(row) {
|
function openConfig(row) {
|
||||||
configUserId.value = row.id
|
configUserId.value = row.id
|
||||||
configForm.value = {
|
configForm.value = {
|
||||||
alert_threshold: row.alert_threshold ? Number(row.alert_threshold) : null,
|
project_name: row.project_name || '',
|
||||||
disable_threshold: row.disable_threshold ? Number(row.disable_threshold) : null,
|
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
|
||||||
monitor_enabled: row.monitor_enabled,
|
monitor_enabled: row.monitor_enabled,
|
||||||
auto_disable_enabled: row.auto_disable_enabled,
|
auto_disable_enabled: row.auto_disable_enabled,
|
||||||
}
|
}
|
||||||
|
newStep.value = null
|
||||||
configVisible.value = true
|
configVisible.value = true
|
||||||
|
if (projects.value.length === 0) loadProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
if (!newStep.value || newStep.value < 1 || newStep.value > 99) {
|
||||||
|
ElMessage.warning('请输入 1-99 之间的百分比')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (configForm.value.alert_thresholds.includes(newStep.value)) {
|
||||||
|
ElMessage.warning('该阈值已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configForm.value.alert_thresholds.push(newStep.value)
|
||||||
|
configForm.value.alert_thresholds.sort((a, b) => a - b)
|
||||||
|
newStep.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(index) {
|
||||||
|
configForm.value.alert_thresholds.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
@ -177,9 +450,28 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewPolicies(row) {
|
// --- Quota History ---
|
||||||
|
async function openQuotaHistory(row) {
|
||||||
|
historyUser.value = row
|
||||||
|
historyVisible.value = true
|
||||||
|
historyLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/v1/iam-users/${row.id}/quota-history/`)
|
||||||
|
quotaHistory.value = data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取划拨记录失败')
|
||||||
|
quotaHistory.value = []
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Policies ---
|
||||||
|
async function openPolicies(row) {
|
||||||
|
policiesUser.value = row
|
||||||
policiesVisible.value = true
|
policiesVisible.value = true
|
||||||
policiesLoading.value = true
|
policiesLoading.value = true
|
||||||
|
policyToAttach.value = ''
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
|
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
|
||||||
policies.value = data.policies || []
|
policies.value = data.policies || []
|
||||||
@ -191,9 +483,78 @@ async function viewPolicies(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAttachPolicy() {
|
||||||
|
if (!policyToAttach.value) return
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/attach/`, {
|
||||||
|
policy_name: policyToAttach.value,
|
||||||
|
policy_type: 'System',
|
||||||
|
})
|
||||||
|
ElMessage.success(`已附加 ${policyToAttach.value}`)
|
||||||
|
policyToAttach.value = ''
|
||||||
|
await openPolicies(policiesUser.value)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '附加失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDetachPolicy(row) {
|
||||||
|
await ElMessageBox.confirm(`确定移除策略 "${row.PolicyName}" 吗?`, '确认移除', { type: 'warning' })
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/detach/`, {
|
||||||
|
policy_name: row.PolicyName,
|
||||||
|
policy_type: row.PolicyType,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已移除 ${row.PolicyName}`)
|
||||||
|
await openPolicies(policiesUser.value)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '移除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create User ---
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.value.username) {
|
||||||
|
ElMessage.warning('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/v1/iam-users/create/', createForm.value)
|
||||||
|
ElMessage.success(data.message)
|
||||||
|
showCreate.value = false
|
||||||
|
createForm.value = { username: '', display_name: '', email: '', phone: '', password: '', project_name: '' }
|
||||||
|
|
||||||
|
// Show secret key if generated
|
||||||
|
if (data.volcengine?.secret_access_key) {
|
||||||
|
createdKeys.value = {
|
||||||
|
access_key_id: data.volcengine.access_key_id,
|
||||||
|
secret_access_key: data.volcengine.secret_access_key,
|
||||||
|
}
|
||||||
|
showSecretKey.value = true
|
||||||
|
}
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyKeys() {
|
||||||
|
const text = `AccessKey ID: ${createdKeys.value.access_key_id}\nSecretAccessKey: ${createdKeys.value.secret_access_key}`
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-hint { font-size: 12px; color: #999; margin-top: 4px; }
|
.form-hint { font-size: 12px; color: #999; margin-top: 4px; }
|
||||||
|
.switch-hint { font-size: 12px; color: #999; margin-left: 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,11 +6,11 @@
|
|||||||
<el-card style="margin-bottom: 20px;">
|
<el-card style="margin-bottom: 20px;">
|
||||||
<template #header><span>全局默认配置</span></template>
|
<template #header><span>全局默认配置</span></template>
|
||||||
<el-form :model="config" label-width="180px" v-loading="loadingConfig">
|
<el-form :model="config" label-width="180px" v-loading="loadingConfig">
|
||||||
<el-form-item label="默认告警阈值(元)">
|
<el-form-item label="默认告警阶梯(%)">
|
||||||
<el-input-number v-model="config.default_alert_threshold" :min="0" :precision="2" />
|
<el-input v-model="alertThresholdsStr" placeholder="50,80,90" />
|
||||||
</el-form-item>
|
<div style="font-size:12px;color:#999;margin-top:4px;">
|
||||||
<el-form-item label="默认停用阈值(元)">
|
逗号分隔的百分比,如 50,80,90 表示消费达到已划拨额度的 50%/80%/90% 时告警
|
||||||
<el-input-number v-model="config.default_disable_threshold" :min="0" :precision="2" />
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="监控间隔(秒)">
|
<el-form-item label="监控间隔(秒)">
|
||||||
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
|
||||||
@ -77,7 +77,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
@ -86,6 +86,17 @@ const config = ref({})
|
|||||||
const loadingConfig = ref(false)
|
const loadingConfig = ref(false)
|
||||||
const savingConfig = ref(false)
|
const savingConfig = ref(false)
|
||||||
|
|
||||||
|
const alertThresholdsStr = computed({
|
||||||
|
get: () => (config.value.default_alert_thresholds || []).join(','),
|
||||||
|
set: (val) => {
|
||||||
|
config.value.default_alert_thresholds = val
|
||||||
|
.split(',')
|
||||||
|
.map(s => parseInt(s.trim()))
|
||||||
|
.filter(n => n >= 1 && n <= 99)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Volc accounts
|
// Volc accounts
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const loadingAccounts = ref(false)
|
const loadingAccounts = ref(false)
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8100',
|
target: 'http://localhost:8101',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,10 +31,10 @@
|
|||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
|
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** |
|
||||||
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
|
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** |
|
||||||
| 子账号能看到自己的账单 | 通过项目 + 标签维度,主账号代查并展示 | **部分可行**(见下方说明)|
|
| 子账号能看到自己的账单 | 通过 AirGate 按项目维度查询,主账号代查展示 | **部分可行**(见下方说明)|
|
||||||
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
|
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** |
|
||||||
| 消费达到阈值发告警 | 定时轮询 Billing API + Webhook/飞书通知 | **完全可行** |
|
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** |
|
||||||
| 消费达到阈值自动停用 | 轮询消费 + 调用 IAM API 停用用户和密钥 | **完全可行** |
|
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** |
|
||||||
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** |
|
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** |
|
||||||
|
|
||||||
### 1.2 架构图
|
### 1.2 架构图
|
||||||
@ -594,21 +594,34 @@ balance = billing_client.call("QueryBalanceAcct")
|
|||||||
- 仅支持在线推理(不含批量)
|
- 仅支持在线推理(不含批量)
|
||||||
- 目前仅支持通过控制台设置,**暂无公开 API**
|
- 目前仅支持通过控制台设置,**暂无公开 API**
|
||||||
|
|
||||||
### 7.4 自建告警方案(推荐)
|
### 7.4 AirGate 自建方案:额度划拨制 + 阶梯式告警
|
||||||
|
|
||||||
由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。需要自建:
|
由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。AirGate 采用**额度划拨制**自建:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
主账号通过 AirGate 给子账号划拨额度(如 10 万元)
|
||||||
│ 定时任务 (每小时/每天) │
|
│
|
||||||
│ │
|
▼ 定时任务每小时查询 Billing API
|
||||||
│ 1. 调用 ListBillDetail 查询各子账号消费 │
|
累计消费不断增长,对比已划拨额度
|
||||||
│ 2. 与预设阈值对比 │
|
│
|
||||||
│ 3. 达到告警阈值 → 发送通知 │
|
├── 消费达到额度 50% → 飞书告警
|
||||||
│ 4. 达到停用阈值 → 调用 IAM API 停用用户 │
|
├── 消费达到额度 80% → 飞书告警
|
||||||
└──────────────────────────────────────────────┘
|
├── 消费达到额度 90% → 飞书告警
|
||||||
|
└── 消费达到额度 100% → 自动停用子账号 + 飞书告警
|
||||||
|
|
||||||
|
额度用完 → 主账号在 AirGate 追加额度 → 告警状态自动重置 → 恢复子账号
|
||||||
|
额度给多了 → 主账号在 AirGate 扣减额度 → 告警状态自动重置
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**关键设计:**
|
||||||
|
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
|
||||||
|
- **可追加可扣减**:主账号可随时追加额度(+5万)或扣减额度(-3万),支持灵活调整
|
||||||
|
- **扣减保护**:扣减后总额度不能低于已消费金额(否则会立即触发停用)
|
||||||
|
- **阶梯式告警**:每个子账号可自定义告警百分比(如 [50, 80, 90]),每档只通知一次
|
||||||
|
- **额度变更即重置告警**:追加或扣减额度后,已触发的告警状态自动清空,按新的使用率重新计算
|
||||||
|
- **累计消费**:跨月累计,通过 Billing API 各月数据求和得出
|
||||||
|
- **操作留痕**:每次划拨/扣减都记录操作人、金额、备注,可追溯
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 子账号自动停用/恢复方案
|
## 8. 子账号自动停用/恢复方案
|
||||||
@ -915,10 +928,9 @@ iam.call("AttachUserPolicy", {
|
|||||||
#### Step 4:创建项目并分配
|
#### Step 4:创建项目并分配
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 使用项目管理 API(通过 open.volcengineapi.com)
|
# 项目管理 API(与 IAM 共用端点,但 Version 不同)
|
||||||
# 注意:项目管理的 service 签名名称需要根据实际情况确认,
|
project_client = VolcengineClient(AK, SK, "iam", "iam.volcengineapi.com",
|
||||||
# 可能是 "resource_manager" 或其他名称,建议先用 API Explorer 测试
|
version="2021-08-01")
|
||||||
project_client = VolcengineClient(AK, SK, "resource_manager", "open.volcengineapi.com")
|
|
||||||
|
|
||||||
# 创建项目
|
# 创建项目
|
||||||
project_client.call("CreateProject", {
|
project_client.call("CreateProject", {
|
||||||
@ -982,153 +994,65 @@ def get_user_spending(bill_period: str, project_name: str = None) -> float:
|
|||||||
return total
|
return total
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三阶段:告警与自动停用
|
### 第三阶段:告警与自动停用(已在 AirGate 中实现)
|
||||||
|
|
||||||
#### Step 6:消费监控与告警服务
|
> 以下逻辑已通过 AirGate 管理平台实现,不再需要手写脚本。
|
||||||
|
> 详见 `backend/utils/scheduler.py` 和 `backend/apps/monitor/views.py`。
|
||||||
|
|
||||||
```python
|
**AirGate 实现的核心流程:**
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import requests as http_requests
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
1. 主账号通过界面给子账号**划拨额度**(如 10 万元)
|
||||||
logger = logging.getLogger("volcengine_monitor")
|
2. 定时任务每小时调用 Billing API 查询累计消费
|
||||||
|
3. 消费达到额度的阶梯百分比(如 50%/80%/90%)时 → 飞书告警
|
||||||
|
4. 消费达到 100% → 自动停用子账号 + 飞书告警
|
||||||
|
5. 主账号可随时追加额度(告警状态自动重置)→ 恢复子账号
|
||||||
|
|
||||||
# 配置
|
**告警状态管理:**
|
||||||
ALERT_THRESHOLD = 1000.0 # 告警阈值(元)
|
- 每个阶梯只通知一次,通过 `triggered_alerts` 字段(存数据库)去重
|
||||||
DISABLE_THRESHOLD = 5000.0 # 停用阈值(元)
|
- 追加额度时自动重置 `triggered_alerts`,按新使用率重新计算
|
||||||
CHECK_INTERVAL = 3600 # 检查间隔(秒),每小时
|
- 不需要月度重置,因为是额度制而非月度制
|
||||||
|
|
||||||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK_ID"
|
#### Step 6:AirGate 已实现的 API 接口
|
||||||
|
|
||||||
MANAGED_USERS = [
|
|
||||||
{
|
|
||||||
"username": "dept_a_user",
|
|
||||||
"project": "DeptA-Project",
|
|
||||||
"alert_threshold": ALERT_THRESHOLD,
|
|
||||||
"disable_threshold": DISABLE_THRESHOLD,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# 记录已停用的用户,避免重复停用和重复告警
|
|
||||||
_disabled_users: set = set()
|
|
||||||
# 记录已发送告警的用户,避免每轮都重复告警
|
|
||||||
_alerted_users: set = set()
|
|
||||||
|
|
||||||
|
|
||||||
def send_feishu_alert(title: str, content: str):
|
|
||||||
"""发送飞书告警"""
|
|
||||||
payload = {
|
|
||||||
"msg_type": "interactive",
|
|
||||||
"card": {
|
|
||||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
|
||||||
"elements": [
|
|
||||||
{"tag": "div", "text": {"tag": "plain_text", "content": content}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
resp = http_requests.post(FEISHU_WEBHOOK, json=payload, timeout=10)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"飞书告警发送失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_and_alert():
|
|
||||||
"""检查消费并触发告警/停用"""
|
|
||||||
from datetime import datetime
|
|
||||||
bill_period = datetime.now().strftime("%Y-%m")
|
|
||||||
|
|
||||||
for user in MANAGED_USERS:
|
|
||||||
username = user["username"]
|
|
||||||
|
|
||||||
# 已停用的用户跳过(避免重复停用和告警)
|
|
||||||
if username in _disabled_users:
|
|
||||||
logger.info(f"用户 {username} 已处于停用状态,跳过检查")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
spending = get_user_spending(bill_period, user["project"])
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(f"查询用户 {username} 消费失败: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"用户 {username} 本月消费: ¥{spending:.2f}")
|
|
||||||
|
|
||||||
if spending >= user["disable_threshold"]:
|
|
||||||
# 自动停用(自动查询该用户的 AccessKey)
|
|
||||||
try:
|
|
||||||
disable_sub_user(iam, username)
|
|
||||||
_disabled_users.add(username)
|
|
||||||
send_feishu_alert(
|
|
||||||
"【紧急】子账号已自动停用",
|
|
||||||
f"用户 {username} 本月消费 ¥{spending:.2f},"
|
|
||||||
f"已达到停用阈值 ¥{user['disable_threshold']:.2f},已自动停用。\n"
|
|
||||||
f"如需恢复,请在管控工具中操作。"
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(f"停用用户 {username} 失败: {e}")
|
|
||||||
send_feishu_alert("【错误】自动停用失败",
|
|
||||||
f"用户 {username} 消费 ¥{spending:.2f},停用失败: {e}")
|
|
||||||
|
|
||||||
elif spending >= user["alert_threshold"] and username not in _alerted_users:
|
|
||||||
# 发送告警(每账期只告警一次)
|
|
||||||
_alerted_users.add(username)
|
|
||||||
send_feishu_alert(
|
|
||||||
"【警告】子账号消费告警",
|
|
||||||
f"用户 {username} 本月消费 ¥{spending:.2f},"
|
|
||||||
f"已达到告警阈值 ¥{user['alert_threshold']:.2f}。\n"
|
|
||||||
f"停用阈值:¥{user['disable_threshold']:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 主循环(生产环境建议用 cron 或 APScheduler)
|
|
||||||
def main():
|
|
||||||
logger.info("消费监控服务启动")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
check_and_alert()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"监控服务异常: {e}")
|
|
||||||
send_feishu_alert("【错误】监控服务异常", str(e))
|
|
||||||
time.sleep(CHECK_INTERVAL)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:每月初应重置 `_disabled_users` 和 `_alerted_users` 集合(或使用持久化存储),
|
|
||||||
> 否则跨月后状态不正确。生产环境建议将状态存入数据库或 Redis。
|
|
||||||
|
|
||||||
#### Step 7:管理后台 API 接口设计
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# 用户管理
|
# 仪表盘
|
||||||
GET /api/iam/users # 列出所有子账号
|
GET /api/v1/dashboard/ # 总览(用户数/消费/告警)
|
||||||
POST /api/iam/users # 创建子账号
|
|
||||||
GET /api/iam/users/{username} # 查询子账号详情
|
|
||||||
PUT /api/iam/users/{username} # 更新子账号
|
|
||||||
DELETE /api/iam/users/{username} # 删除子账号
|
|
||||||
|
|
||||||
# 启停控制
|
# 火山主账号管理
|
||||||
POST /api/iam/users/{username}/disable # 停用子账号
|
GET /api/v1/volc-accounts/ # 列出主账号
|
||||||
POST /api/iam/users/{username}/enable # 恢复子账号
|
POST /api/v1/volc-accounts/ # 添加主账号(AK/SK 加密存储)
|
||||||
|
PUT /api/v1/volc-accounts/{id}/ # 更新主账号
|
||||||
|
DELETE /api/v1/volc-accounts/{id}/ # 删除主账号
|
||||||
|
POST /api/v1/volc-accounts/{id}/test/ # 测试密钥有效性
|
||||||
|
|
||||||
# 权限管理
|
# IAM 子账号管理
|
||||||
GET /api/iam/users/{username}/policies # 查看子账号权限
|
GET /api/v1/iam-users/ # 列出所有子账号
|
||||||
POST /api/iam/users/{username}/policies # 附加权限
|
POST /api/v1/iam-users/sync/ # 从火山同步全部子账号
|
||||||
DELETE /api/iam/users/{username}/policies # 移除权限
|
POST /api/v1/iam-users/import/ # 导入指定子账号
|
||||||
|
GET /api/v1/iam-users/{id}/ # 查询子账号详情
|
||||||
|
PUT /api/v1/iam-users/{id}/update/ # 更新配置(告警阈值/开关)
|
||||||
|
POST /api/v1/iam-users/{id}/disable/ # 停用子账号
|
||||||
|
POST /api/v1/iam-users/{id}/enable/ # 恢复子账号
|
||||||
|
GET /api/v1/iam-users/{id}/policies/ # 查看权限策略
|
||||||
|
|
||||||
# 密钥管理
|
# 额度管理
|
||||||
GET /api/iam/users/{username}/access-keys # 列出密钥
|
POST /api/v1/iam-users/{id}/allocate/ # 追加额度(正数)或扣减额度(负数)
|
||||||
POST /api/iam/users/{username}/access-keys # 创建密钥
|
GET /api/v1/iam-users/{id}/quota-history/ # 查看额度变更记录(含追加和扣减)
|
||||||
PUT /api/iam/users/{username}/access-keys/{id} # 启停密钥
|
|
||||||
|
|
||||||
# 消费查询
|
# 消费查询
|
||||||
GET /api/billing/users/{username}/spending # 查询子账号消费
|
GET /api/v1/billing/overview/ # 消费总览
|
||||||
GET /api/billing/overview # 消费总览
|
POST /api/v1/billing/refresh/ # 手动刷新消费数据
|
||||||
|
GET /api/v1/billing/balance/ # 主账号余额
|
||||||
|
|
||||||
# 告警配置
|
# 全局配置
|
||||||
GET /api/alerts/config # 查看告警配置
|
GET /api/v1/config/ # 查看全局配置
|
||||||
PUT /api/alerts/config # 更新阈值配置
|
PUT /api/v1/config/ # 更新全局配置
|
||||||
GET /api/alerts/history # 告警历史
|
|
||||||
|
# 告警记录
|
||||||
|
GET /api/v1/alerts/ # 告警历史(支持类型筛选)
|
||||||
|
|
||||||
|
# 项目列表
|
||||||
|
GET /api/v1/projects/ # 从火山拉取项目列表
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1145,7 +1069,7 @@ GET /api/alerts/history # 告警历史
|
|||||||
| SecretKey 仅返回一次 | 创建后立即保存 |
|
| SecretKey 仅返回一次 | 创建后立即保存 |
|
||||||
| Billing API QPS 限制 5 | 批量查询需注意限流 |
|
| Billing API QPS 限制 5 | 批量查询需注意限流 |
|
||||||
| Ark 推理限额无公开 API | 目前仅支持控制台操作 |
|
| Ark 推理限额无公开 API | 目前仅支持控制台操作 |
|
||||||
| 预算告警仅通知不自动执行 | 需自建自动停用逻辑 |
|
| 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
|
||||||
|
|
||||||
### 12.2 安全建议
|
### 12.2 安全建议
|
||||||
|
|
||||||
@ -1157,10 +1081,11 @@ GET /api/alerts/history # 告警历史
|
|||||||
|
|
||||||
### 12.3 消费监控的精确度问题
|
### 12.3 消费监控的精确度问题
|
||||||
|
|
||||||
由于账单数据有 1-2 天延迟,消费监控存在滞后。应对策略:
|
由于账单数据有 1-2 天延迟,消费监控存在滞后。AirGate 的应对策略:
|
||||||
- 设置更保守的阈值(如实际想控制 5000 元,告警阈值设 3000,停用阈值设 4000)
|
- **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90])
|
||||||
- 结合 Ark 推理限额功能(自动暂停,无延迟)
|
- **阶梯式告警**:在额度用尽前的多个节点提前告警,给管理员反应时间
|
||||||
- 高频轮询(如每小时)以尽早发现异常
|
- **高频轮询**:每小时查一次,虽然数据本身有延迟,但能在数据更新后第一时间触发告警
|
||||||
|
- 结合 Ark 推理限额功能(控制台手动设置,自动暂停,无延迟)作为兜底
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1215,4 +1140,8 @@ GET /api/alerts/history # 告警历史
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **下一步行动**:基于此报告,可以开始开发管控工具的后端服务。建议使用 Python + `volcengine-python-sdk`,先实现核心的 IAM 管理和消费监控功能,再逐步集成到 AirDrama 管理后台。
|
> **当前进度**:AirGate 管控工具已完成核心功能开发(Django 4.2 + DRF + Vue 3 + Element Plus),
|
||||||
|
> 包括 IAM 子账号管理、额度划拨、阶梯式告警、消费监控、飞书通知。
|
||||||
|
> 项目仓库:https://gitea.airlabs.art/seaislee/AirGate.git
|
||||||
|
>
|
||||||
|
> **待完成**:创建子账号功能、权限策略配置界面、Docker/K8s 部署配置、飞书联调、AirDrama API 对接。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user