feat: multi-project per sub-account support
Data model: - Add IAMUserProject model (sub-account → N projects, each with monitoring toggle) - Remove old single project_name from IAMUser model - Update SpendingRecord with per-project granularity Backend: - Project CRUD views: list/add/update-toggle/delete/toggle-all - Create user view auto-adds first project if specified - Scheduler aggregates spending across all enabled projects per user - Per-project spending recorded in SpendingRecord + IAMUserProject.current_spending - Alert details include per-project spending breakdown Frontend: - New "项目管理" dialog: add projects from Volcengine dropdown, toggle monitoring per project, remove projects, batch toggle all - "项目" column in user table showing monitored/total count (clickable) - BillingView: expandable rows showing per-project spending breakdown - Create dialog: optional initial project selection - Removed old single-project select from config dialog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fed282f1e
commit
1e94241587
@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
|
||||
|
||||
@admin.register(VolcAccount)
|
||||
@ -7,11 +7,23 @@ class VolcAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'access_key_hint', 'is_active', 'updated_at')
|
||||
|
||||
|
||||
class IAMUserProjectInline(admin.TabularInline):
|
||||
model = IAMUserProject
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(IAMUser)
|
||||
class IAMUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
|
||||
'allocated_quota', 'consumed_total')
|
||||
list_filter = ('status', 'monitor_enabled')
|
||||
inlines = [IAMUserProjectInline]
|
||||
|
||||
|
||||
@admin.register(IAMUserProject)
|
||||
class IAMUserProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ('iam_user', 'project_name', 'monitor_enabled', 'current_spending')
|
||||
list_filter = ('monitor_enabled',)
|
||||
|
||||
|
||||
@admin.register(QuotaAllocation)
|
||||
@ -32,4 +44,4 @@ class AlertRecordAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SpendingRecord)
|
||||
class SpendingRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('iam_user', 'bill_period', 'amount', 'updated_at')
|
||||
list_display = ('iam_user', 'project_name', 'bill_period', 'amount', 'updated_at')
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-19 12:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0003_remove_globalconfig_default_monthly_budget_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='spendingrecord',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='iamuser',
|
||||
name='project_name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='spendingrecord',
|
||||
name='project_name',
|
||||
field=models.CharField(blank=True, help_text='空=子账号总消费', max_length=200, verbose_name='项目名'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='quotaallocation',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=12, verbose_name='变更金额(元,正=追加,负=扣减)'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='spendingrecord',
|
||||
unique_together={('iam_user', 'project_name', 'bill_period')},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='spendingrecord',
|
||||
name='detail',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IAMUserProject',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('project_name', models.CharField(max_length=200, verbose_name='火山项目名')),
|
||||
('display_name', models.CharField(blank=True, max_length=200, verbose_name='显示名')),
|
||||
('monitor_enabled', models.BooleanField(default=True, verbose_name='启用监测')),
|
||||
('current_spending', models.DecimalField(decimal_places=2, default=0, help_text='此项目的累计消费,由定时任务更新', max_digits=12, verbose_name='当前消费(元)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='monitor.iamuser')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '子账号关联项目',
|
||||
'verbose_name_plural': '子账号关联项目',
|
||||
'db_table': 'airgate_iam_user_project',
|
||||
'ordering': ['project_name'],
|
||||
'unique_together': {('iam_user', 'project_name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -35,8 +35,6 @@ class IAMUser(models.Model):
|
||||
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
|
||||
email = models.EmailField('邮箱', blank=True)
|
||||
phone = models.CharField('手机号', max_length=20, blank=True)
|
||||
project_name = models.CharField('关联项目名', max_length=200, blank=True,
|
||||
help_text='用于按项目维度追踪消费')
|
||||
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
|
||||
|
||||
# Access keys (stored as JSON list of AK IDs, not secrets)
|
||||
@ -90,6 +88,28 @@ class IAMUser(models.Model):
|
||||
return sorted(config.default_alert_thresholds or [50, 80, 90])
|
||||
|
||||
|
||||
class IAMUserProject(models.Model):
|
||||
"""子账号关联的火山项目(多对多)"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='projects')
|
||||
project_name = models.CharField('火山项目名', max_length=200)
|
||||
display_name = models.CharField('显示名', max_length=200, blank=True)
|
||||
monitor_enabled = models.BooleanField('启用监测', default=True)
|
||||
current_spending = models.DecimalField('当前消费(元)', max_digits=12, decimal_places=2, default=0,
|
||||
help_text='此项目的累计消费,由定时任务更新')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '子账号关联项目'
|
||||
verbose_name_plural = '子账号关联项目'
|
||||
db_table = 'airgate_iam_user_project'
|
||||
unique_together = [('iam_user', 'project_name')]
|
||||
ordering = ['project_name']
|
||||
|
||||
def __str__(self):
|
||||
status = '监测中' if self.monitor_enabled else '未监测'
|
||||
return f"{self.project_name} ({status}) ¥{self.current_spending}"
|
||||
|
||||
|
||||
class QuotaAllocation(models.Model):
|
||||
"""额度划拨记录"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
|
||||
@ -161,18 +181,20 @@ class AlertRecord(models.Model):
|
||||
|
||||
|
||||
class SpendingRecord(models.Model):
|
||||
"""月度消费快照"""
|
||||
"""月度消费快照(按项目粒度)"""
|
||||
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
|
||||
project_name = models.CharField('项目名', max_length=200, blank=True,
|
||||
help_text='空=子账号总消费')
|
||||
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
|
||||
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
|
||||
detail = models.JSONField('消费明细', default=dict, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '消费记录'
|
||||
verbose_name_plural = '消费记录'
|
||||
db_table = 'airgate_spending_record'
|
||||
unique_together = [('iam_user', 'bill_period')]
|
||||
unique_together = [('iam_user', 'project_name', 'bill_period')]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
|
||||
proj = self.project_name or '总计'
|
||||
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
|
||||
|
||||
class VolcAccountSerializer(serializers.ModelSerializer):
|
||||
@ -15,21 +15,32 @@ class VolcAccountCreateSerializer(serializers.Serializer):
|
||||
secret_key = serializers.CharField(write_only=True)
|
||||
|
||||
|
||||
class IAMUserProjectSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = IAMUserProject
|
||||
fields = ['id', 'project_name', 'display_name', 'monitor_enabled',
|
||||
'current_spending', 'created_at']
|
||||
read_only_fields = ['current_spending', 'created_at']
|
||||
|
||||
|
||||
class IAMUserSerializer(serializers.ModelSerializer):
|
||||
remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||
usage_percent = serializers.FloatField(read_only=True)
|
||||
effective_alert_thresholds = serializers.SerializerMethodField()
|
||||
projects = IAMUserProjectSerializer(many=True, read_only=True)
|
||||
monitored_project_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = IAMUser
|
||||
fields = [
|
||||
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
|
||||
'project_name', 'status', 'access_key_ids',
|
||||
'status', 'access_key_ids',
|
||||
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
|
||||
'spending_updated_at',
|
||||
'monitor_enabled', 'auto_disable_enabled',
|
||||
'alert_thresholds', 'triggered_alerts',
|
||||
'effective_alert_thresholds',
|
||||
'projects', 'monitored_project_count',
|
||||
'remark', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||
@ -40,6 +51,9 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
||||
def get_effective_alert_thresholds(self, obj):
|
||||
return obj.get_alert_thresholds()
|
||||
|
||||
def get_monitored_project_count(self, obj):
|
||||
return obj.projects.filter(monitor_enabled=True).count()
|
||||
|
||||
|
||||
class IAMUserCreateSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=200)
|
||||
@ -47,7 +61,8 @@ class IAMUserCreateSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField(required=False, default='')
|
||||
phone = serializers.CharField(max_length=20, required=False, default='')
|
||||
password = serializers.CharField(write_only=True, required=False, default='')
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='',
|
||||
help_text='可选,创建后自动关联此项目')
|
||||
|
||||
|
||||
class IAMUserImportSerializer(serializers.Serializer):
|
||||
@ -55,8 +70,6 @@ class IAMUserImportSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class IAMUserConfigSerializer(serializers.Serializer):
|
||||
"""子账号配置更新"""
|
||||
project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||
alert_thresholds = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1, max_value=99),
|
||||
required=False,
|
||||
@ -65,6 +78,16 @@ class IAMUserConfigSerializer(serializers.Serializer):
|
||||
auto_disable_enabled = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class IAMUserProjectAddSerializer(serializers.Serializer):
|
||||
project_name = serializers.CharField(max_length=200)
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
monitor_enabled = serializers.BooleanField(required=False, default=True)
|
||||
|
||||
|
||||
class IAMUserProjectUpdateSerializer(serializers.Serializer):
|
||||
monitor_enabled = serializers.BooleanField()
|
||||
|
||||
|
||||
class QuotaAllocateSerializer(serializers.Serializer):
|
||||
"""额度变更:正数=追加,负数=扣减"""
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
@ -106,14 +129,6 @@ class AlertRecordSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SpendingRecordSerializer(serializers.ModelSerializer):
|
||||
iam_username = serializers.CharField(source='iam_user.username')
|
||||
|
||||
class Meta:
|
||||
model = SpendingRecord
|
||||
fields = ['id', 'iam_user', 'iam_username', 'bill_period', 'amount', 'updated_at']
|
||||
|
||||
|
||||
class DashboardSerializer(serializers.Serializer):
|
||||
total_users = serializers.IntegerField()
|
||||
active_users = serializers.IntegerField()
|
||||
|
||||
@ -22,6 +22,14 @@ urlpatterns = [
|
||||
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),
|
||||
# IAM user projects (multi-project)
|
||||
path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view),
|
||||
path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view),
|
||||
path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view),
|
||||
path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view),
|
||||
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),
|
||||
|
||||
# Quota
|
||||
path('iam-users/<int:pk>/allocate/', views.quota_allocate_view),
|
||||
path('iam-users/<int:pk>/quota-history/', views.quota_history_view),
|
||||
|
||||
|
||||
@ -14,11 +14,13 @@ from utils.iam_service import IAMService, ProjectService
|
||||
from utils.billing_service import BillingService
|
||||
from utils.volcengine_client import VolcengineAPIError
|
||||
|
||||
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
|
||||
from .serializers import (
|
||||
VolcAccountSerializer, VolcAccountCreateSerializer,
|
||||
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
|
||||
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
IAMUserConfigSerializer,
|
||||
IAMUserProjectSerializer, IAMUserProjectAddSerializer, IAMUserProjectUpdateSerializer,
|
||||
QuotaAllocateSerializer, QuotaAllocationSerializer,
|
||||
GlobalConfigSerializer,
|
||||
AlertRecordSerializer,
|
||||
DashboardSerializer,
|
||||
@ -272,11 +274,19 @@ def iam_user_create_view(request):
|
||||
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 [],
|
||||
)
|
||||
|
||||
# 6. Auto-add project if specified
|
||||
project_name = d.get('project_name', '')
|
||||
if project_name:
|
||||
IAMUserProject.objects.create(
|
||||
iam_user=obj,
|
||||
project_name=project_name,
|
||||
monitor_enabled=True,
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=obj,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
@ -499,6 +509,91 @@ def iam_user_detach_policy_view(request, pk):
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
# ==================== IAM User Projects ====================
|
||||
|
||||
@api_view(['GET'])
|
||||
def iam_user_project_list_view(request, pk):
|
||||
"""查看子账号关联的项目列表"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
projects = user.projects.all()
|
||||
return Response(IAMUserProjectSerializer(projects, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_project_add_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 = IAMUserProjectAddSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
d = serializer.validated_data
|
||||
|
||||
obj, created = IAMUserProject.objects.get_or_create(
|
||||
iam_user=user,
|
||||
project_name=d['project_name'],
|
||||
defaults={
|
||||
'display_name': d.get('display_name', ''),
|
||||
'monitor_enabled': d.get('monitor_enabled', True),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
return Response({'error': 'duplicate', 'message': f'项目 {d["project_name"]} 已关联'},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
|
||||
return Response({
|
||||
'message': f'已关联项目 {d["project_name"]}',
|
||||
'project': IAMUserProjectSerializer(obj).data,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['PUT'])
|
||||
def iam_user_project_update_view(request, pk, pid):
|
||||
"""更新项目监测开关"""
|
||||
try:
|
||||
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
|
||||
except IAMUserProject.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = IAMUserProjectUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
project.monitor_enabled = serializer.validated_data['monitor_enabled']
|
||||
project.save(update_fields=['monitor_enabled'])
|
||||
return Response(IAMUserProjectSerializer(project).data)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def iam_user_project_delete_view(request, pk, pid):
|
||||
"""移除关联项目"""
|
||||
try:
|
||||
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
|
||||
except IAMUserProject.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
name = project.project_name
|
||||
project.delete()
|
||||
return Response({'message': f'已移除项目 {name}'})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def iam_user_project_toggle_all_view(request, pk):
|
||||
"""批量切换所有项目的监测开关"""
|
||||
try:
|
||||
user = IAMUser.objects.get(pk=pk)
|
||||
except IAMUser.DoesNotExist:
|
||||
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
enable = request.data.get('monitor_enabled', True)
|
||||
count = user.projects.update(monitor_enabled=enable)
|
||||
label = '开启' if enable else '关闭'
|
||||
return Response({'message': f'已{label}全部 {count} 个项目的监测'})
|
||||
|
||||
|
||||
# ==================== Quota Allocation ====================
|
||||
|
||||
@api_view(['POST'])
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
|
||||
"""定时消费监控任务 -- 多项目聚合 + 额度划拨制 + 阶梯式告警"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
@ -10,8 +10,8 @@ _scheduler_started = False
|
||||
|
||||
|
||||
def check_spending():
|
||||
"""定时检查所有子账号消费,对比已划拨额度触发阶梯告警"""
|
||||
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
|
||||
"""定时检查所有子账号消费:遍历开启监测的项目,聚合消费,触发阶梯告警"""
|
||||
from apps.monitor.models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord
|
||||
from utils.crypto import decrypt
|
||||
from utils.billing_service import BillingService
|
||||
from utils.iam_service import IAMService
|
||||
@ -19,6 +19,7 @@ def check_spending():
|
||||
|
||||
config = GlobalConfig.get_solo()
|
||||
webhook = config.feishu_webhook_url
|
||||
bill_period = timezone.now().strftime("%Y-%m")
|
||||
|
||||
for volc_account in VolcAccount.objects.filter(is_active=True):
|
||||
ak = decrypt(volc_account.access_key_enc)
|
||||
@ -37,25 +38,50 @@ def check_spending():
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# 查询当月消费(按项目筛选)
|
||||
bill_period = timezone.now().strftime("%Y-%m")
|
||||
spending = billing.get_spending_by_project(
|
||||
bill_period, user.project_name or None
|
||||
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
|
||||
enabled_projects = IAMUserProject.objects.filter(
|
||||
iam_user=user, monitor_enabled=True
|
||||
)
|
||||
|
||||
# 记录月度快照
|
||||
if not enabled_projects.exists():
|
||||
logger.info(f"用户 {user.username} 无开启监测的项目,跳过")
|
||||
continue
|
||||
|
||||
total_spending = Decimal('0')
|
||||
|
||||
for project in enabled_projects:
|
||||
try:
|
||||
proj_spending = billing.get_spending_by_project(
|
||||
bill_period, project.project_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询项目 {project.project_name} 消费失败: {e}")
|
||||
proj_spending = project.current_spending # 保留上次值
|
||||
|
||||
# 更新项目级消费
|
||||
project.current_spending = proj_spending
|
||||
project.save(update_fields=['current_spending'])
|
||||
|
||||
# 记录项目级月度快照
|
||||
SpendingRecord.objects.update_or_create(
|
||||
iam_user=user, bill_period=bill_period,
|
||||
defaults={'amount': spending},
|
||||
iam_user=user,
|
||||
project_name=project.project_name,
|
||||
bill_period=bill_period,
|
||||
defaults={'amount': proj_spending},
|
||||
)
|
||||
|
||||
# 累计消费 = 所有月份的消费之和
|
||||
total_spending += proj_spending
|
||||
|
||||
# 更新子账号总消费
|
||||
# 累计消费 = 所有月份的所有开启监测项目的消费之和
|
||||
all_enabled_names = list(enabled_projects.values_list('project_name', flat=True))
|
||||
from django.db.models import Sum
|
||||
total = SpendingRecord.objects.filter(
|
||||
iam_user=user
|
||||
cumulative = SpendingRecord.objects.filter(
|
||||
iam_user=user,
|
||||
project_name__in=all_enabled_names,
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
user.consumed_total = total
|
||||
user.consumed_total = cumulative
|
||||
user.spending_updated_at = timezone.now()
|
||||
|
||||
quota = user.allocated_quota
|
||||
@ -63,7 +89,7 @@ def check_spending():
|
||||
user.save(update_fields=['consumed_total', 'spending_updated_at'])
|
||||
continue
|
||||
|
||||
usage_percent = float(total) / float(quota) * 100
|
||||
usage_percent = float(cumulative) / float(quota) * 100
|
||||
triggered = user.triggered_alerts or []
|
||||
|
||||
# --- 阶梯式告警 ---
|
||||
@ -72,16 +98,23 @@ def check_spending():
|
||||
triggered.append(step)
|
||||
threshold_amount = Decimal(str(quota)) * step / 100
|
||||
|
||||
# 构建项目明细
|
||||
detail_lines = "\n".join(
|
||||
f" {p.project_name}: ¥{p.current_spending}"
|
||||
for p in enabled_projects
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.WARNING,
|
||||
title=f"{user.username} 消费达到额度 {step}%",
|
||||
content=(
|
||||
f"累计消费 ¥{total:.2f},"
|
||||
f"累计消费 ¥{cumulative:.2f},"
|
||||
f"已划拨额度 ¥{quota:.2f} 的 {step}%\n"
|
||||
f"剩余额度: ¥{user.remaining_quota:.2f}"
|
||||
f"剩余额度: ¥{user.remaining_quota:.2f}\n"
|
||||
f"项目明细:\n{detail_lines}"
|
||||
),
|
||||
spending_amount=total,
|
||||
spending_amount=cumulative,
|
||||
threshold_amount=threshold_amount,
|
||||
notified=True,
|
||||
)
|
||||
@ -89,10 +122,12 @@ def check_spending():
|
||||
webhook,
|
||||
f"⚠️ {user.username} 消费达到额度 {step}%",
|
||||
f"**用户**: {user.username}\n"
|
||||
f"**累计消费**: ¥{total:.2f}\n"
|
||||
f"**累计消费**: ¥{cumulative:.2f}\n"
|
||||
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
|
||||
f"**使用率**: {usage_percent:.1f}%",
|
||||
f"**使用率**: {usage_percent:.1f}%\n"
|
||||
f"**监测项目数**: {enabled_projects.count()}\n"
|
||||
f"**项目明细**:\n{detail_lines}",
|
||||
template="orange" if step < 90 else "red",
|
||||
)
|
||||
|
||||
@ -108,15 +143,21 @@ def check_spending():
|
||||
except Exception as e:
|
||||
logger.error(f"停用用户 {user.username} 失败: {e}")
|
||||
|
||||
detail_lines = "\n".join(
|
||||
f" {p.project_name}: ¥{p.current_spending}"
|
||||
for p in enabled_projects
|
||||
)
|
||||
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.DISABLE,
|
||||
title=f"{user.username} 额度用尽,已自动停用",
|
||||
content=(
|
||||
f"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
|
||||
f"累计消费 ¥{cumulative:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
|
||||
f"项目明细:\n{detail_lines}\n"
|
||||
f"如需继续使用,请划拨新额度后恢复账号。"
|
||||
),
|
||||
spending_amount=total,
|
||||
spending_amount=cumulative,
|
||||
threshold_amount=quota,
|
||||
notified=True,
|
||||
)
|
||||
@ -124,8 +165,9 @@ def check_spending():
|
||||
webhook,
|
||||
f"🚨 {user.username} 额度用尽,已自动停用",
|
||||
f"**用户**: {user.username}\n"
|
||||
f"**累计消费**: ¥{total:.2f}\n"
|
||||
f"**累计消费**: ¥{cumulative:.2f}\n"
|
||||
f"**已划拨额度**: ¥{quota:.2f}\n"
|
||||
f"**项目明细**:\n{detail_lines}\n"
|
||||
f"额度已用尽,账号已自动停用。\n"
|
||||
f"请在 AirGate 划拨新额度后恢复。",
|
||||
template="red",
|
||||
|
||||
@ -24,10 +24,37 @@
|
||||
<span>各子账号消费与额度</span>
|
||||
</template>
|
||||
<el-table :data="overview.users || []" stripe v-loading="loading" table-layout="auto"
|
||||
:default-sort="{ prop: 'consumed_total', order: 'descending' }">
|
||||
:default-sort="{ prop: 'consumed_total', order: 'descending' }"
|
||||
row-key="id">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div style="padding: 8px 48px;" v-if="(row.projects || []).length">
|
||||
<el-table :data="row.projects" size="small" :show-header="true">
|
||||
<el-table-column prop="project_name" label="项目名" min-width="160" />
|
||||
<el-table-column label="消费" min-width="100" align="right">
|
||||
<template #default="{ row: p }">
|
||||
<span style="color:#e6a23c;">¥{{ Number(p.current_spending).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="监测" min-width="80" align="center">
|
||||
<template #default="{ row: p }">
|
||||
<el-tag :type="p.monitor_enabled ? 'success' : 'info'" size="small">
|
||||
{{ p.monitor_enabled ? '开' : '关' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else style="padding:8px 48px; color:#999;">暂无关联项目</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="display_name" label="显示名" min-width="100" />
|
||||
<el-table-column prop="project_name" label="项目" min-width="120" />
|
||||
<el-table-column label="监测项目" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consumed_total" label="累计消费" min-width="110" sortable align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight: 600; color: #e6a23c;">
|
||||
@ -56,15 +83,6 @@
|
||||
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" min-width="110" align="center">
|
||||
<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;">
|
||||
{{ step }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="spending_updated_at" label="更新时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.spending_updated_at ? new Date(row.spending_updated_at).toLocaleString('zh-CN') : '暂无' }}
|
||||
|
||||
@ -51,6 +51,13 @@
|
||||
<span v-else style="color:#999;font-size:12px;">未划拨</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目" min-width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openProjectsDialog(row)">
|
||||
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
|
||||
@ -69,6 +76,7 @@
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openProjectsDialog(row)">项目管理</el-dropdown-item>
|
||||
<el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item>
|
||||
<el-dropdown-item @click="openPolicies(row)">权限策略</el-dropdown-item>
|
||||
<el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item>
|
||||
@ -125,20 +133,8 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Config Dialog -->
|
||||
<el-dialog v-model="configVisible" title="监控配置" width="560px">
|
||||
<el-form :model="configForm" label-width="130px">
|
||||
<el-form-item label="关联项目">
|
||||
<el-select v-model="configForm.project_name" placeholder="选择火山引擎项目"
|
||||
filterable clearable style="width:100%;" :loading="projectsLoading">
|
||||
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<div class="form-hint">
|
||||
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">告警阶梯</el-divider>
|
||||
|
||||
<el-dialog v-model="configVisible" title="监控配置" width="520px">
|
||||
<el-form :model="configForm" label-width="140px">
|
||||
<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"
|
||||
@ -148,9 +144,6 @@
|
||||
</div>
|
||||
<div class="form-hint">达到已划拨额度对应百分比时发送告警</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">开关</el-divider>
|
||||
|
||||
<el-form-item label="消费监控">
|
||||
<el-switch v-model="configForm.monitor_enabled" />
|
||||
</el-form-item>
|
||||
@ -165,6 +158,43 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Projects Dialog -->
|
||||
<el-dialog v-model="projectsDialogVisible" :title="`${projectsUser?.username} 关联项目`" width="680px">
|
||||
<div style="margin-bottom:12px; display:flex; gap:8px; align-items:center;">
|
||||
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="flex:1;"
|
||||
:loading="volcProjectsLoading">
|
||||
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleAddProject" :disabled="!projectToAdd">添加</el-button>
|
||||
<el-button @click="loadVolcProjects" :loading="volcProjectsLoading" text>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button size="small" @click="handleToggleAll(true)">全部开启监测</el-button>
|
||||
<el-button size="small" @click="handleToggleAll(false)">全部关闭监测</el-button>
|
||||
</div>
|
||||
<el-table :data="userProjects" stripe v-loading="projectsDialogLoading" empty-text="暂无关联项目">
|
||||
<el-table-column prop="project_name" label="项目名" min-width="160" />
|
||||
<el-table-column prop="display_name" label="显示名" min-width="120" />
|
||||
<el-table-column label="消费" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color:#e6a23c;">¥{{ Number(row.current_spending).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="监测" min-width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.monitor_enabled" @change="val => handleToggleProject(row, val)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="handleRemoveProject(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Quota History Dialog -->
|
||||
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
|
||||
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
|
||||
@ -235,8 +265,9 @@
|
||||
</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" />
|
||||
style="width:100%;" :loading="volcProjectsLoading"
|
||||
@focus="() => { if (!volcProjects.length) loadVolcProjects() }">
|
||||
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -295,10 +326,6 @@ const configUserId = ref(null)
|
||||
const saving = ref(false)
|
||||
const newStep = ref(null)
|
||||
|
||||
// Projects
|
||||
const projects = ref([])
|
||||
const projectsLoading = ref(false)
|
||||
|
||||
// Quota History
|
||||
const historyVisible = ref(false)
|
||||
const historyUser = ref(null)
|
||||
@ -361,6 +388,15 @@ const policies = ref([])
|
||||
const policiesLoading = ref(false)
|
||||
const policyToAttach = ref('')
|
||||
|
||||
// Projects dialog
|
||||
const projectsDialogVisible = ref(false)
|
||||
const projectsUser = ref(null)
|
||||
const userProjects = ref([])
|
||||
const projectsDialogLoading = ref(false)
|
||||
const projectToAdd = ref('')
|
||||
const volcProjects = ref([])
|
||||
const volcProjectsLoading = ref(false)
|
||||
|
||||
// --- Allocate ---
|
||||
const maxDeduct = computed(() => {
|
||||
if (!allocateUser.value) return 0
|
||||
@ -404,29 +440,101 @@ async function submitAllocate() {
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
async function loadProjects() {
|
||||
projectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
projects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取项目列表失败')
|
||||
} finally {
|
||||
projectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfig(row) {
|
||||
configUserId.value = row.id
|
||||
configForm.value = {
|
||||
project_name: row.project_name || '',
|
||||
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
|
||||
monitor_enabled: row.monitor_enabled,
|
||||
auto_disable_enabled: row.auto_disable_enabled,
|
||||
}
|
||||
newStep.value = null
|
||||
configVisible.value = true
|
||||
if (projects.value.length === 0) loadProjects()
|
||||
}
|
||||
|
||||
// --- Projects Dialog ---
|
||||
async function loadVolcProjects() {
|
||||
volcProjectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取火山项目列表失败')
|
||||
} finally {
|
||||
volcProjectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openProjectsDialog(row) {
|
||||
projectsUser.value = row
|
||||
projectsDialogVisible.value = true
|
||||
projectToAdd.value = ''
|
||||
await loadUserProjects(row.id)
|
||||
if (volcProjects.value.length === 0) loadVolcProjects()
|
||||
}
|
||||
|
||||
async function loadUserProjects(userId) {
|
||||
projectsDialogLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/iam-users/${userId}/projects/`)
|
||||
userProjects.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error('获取项目列表失败')
|
||||
userProjects.value = []
|
||||
} finally {
|
||||
projectsDialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProject() {
|
||||
if (!projectToAdd.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
|
||||
project_name: projectToAdd.value,
|
||||
})
|
||||
ElMessage.success('已添加')
|
||||
projectToAdd.value = ''
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleProject(row, val) {
|
||||
try {
|
||||
await api.put(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/`, {
|
||||
monitor_enabled: val,
|
||||
})
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveProject(row) {
|
||||
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/delete/`)
|
||||
ElMessage.success('已移除')
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAll(enable) {
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/toggle-all/`, {
|
||||
monitor_enabled: enable,
|
||||
})
|
||||
ElMessage.success(data.message)
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user