From 1e94241587bb2675a028f118ea4f58d5e6fdc133 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 19 Mar 2026 20:37:38 +0800 Subject: [PATCH] feat: multi-project per sub-account support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/apps/monitor/admin.py | 16 +- ...spendingrecord_unique_together_and_more.py | 59 ++++++ backend/apps/monitor/models.py | 34 +++- backend/apps/monitor/serializers.py | 41 ++-- backend/apps/monitor/urls.py | 8 + backend/apps/monitor/views.py | 101 +++++++++- backend/utils/scheduler.py | 92 ++++++--- frontend/src/views/billing/BillingView.vue | 40 ++-- frontend/src/views/iam/IAMUserList.vue | 182 ++++++++++++++---- 9 files changed, 476 insertions(+), 97 deletions(-) create mode 100644 backend/apps/monitor/migrations/0004_alter_spendingrecord_unique_together_and_more.py diff --git a/backend/apps/monitor/admin.py b/backend/apps/monitor/admin.py index b9ca733..c8fdbaf 100644 --- a/backend/apps/monitor/admin.py +++ b/backend/apps/monitor/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, 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') diff --git a/backend/apps/monitor/migrations/0004_alter_spendingrecord_unique_together_and_more.py b/backend/apps/monitor/migrations/0004_alter_spendingrecord_unique_together_and_more.py new file mode 100644 index 0000000..ece3c4f --- /dev/null +++ b/backend/apps/monitor/migrations/0004_alter_spendingrecord_unique_together_and_more.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index e629156..d95a423 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -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}" diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index 1bda373..10f9140 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, 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() diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 7937bfb..7acc38f 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -22,6 +22,14 @@ urlpatterns = [ path('iam-users//policies/', views.iam_user_policies_view), path('iam-users//policies/attach/', views.iam_user_attach_policy_view), path('iam-users//policies/detach/', views.iam_user_detach_policy_view), + # IAM user projects (multi-project) + path('iam-users//projects/', views.iam_user_project_list_view), + path('iam-users//projects/add/', views.iam_user_project_add_view), + path('iam-users//projects//', views.iam_user_project_update_view), + path('iam-users//projects//delete/', views.iam_user_project_delete_view), + path('iam-users//projects/toggle-all/', views.iam_user_project_toggle_all_view), + + # Quota path('iam-users//allocate/', views.quota_allocate_view), path('iam-users//quota-history/', views.quota_history_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 9b87e09..b270348 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -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']) diff --git a/backend/utils/scheduler.py b/backend/utils/scheduler.py index 269f9a5..4ec0b62 100644 --- a/backend/utils/scheduler.py +++ b/backend/utils/scheduler.py @@ -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 ) - # 记录月度快照 - SpendingRecord.objects.update_or_create( - iam_user=user, bill_period=bill_period, - defaults={'amount': spending}, - ) + 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, + 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", diff --git a/frontend/src/views/billing/BillingView.vue b/frontend/src/views/billing/BillingView.vue index 64582c3..108049d 100644 --- a/frontend/src/views/billing/BillingView.vue +++ b/frontend/src/views/billing/BillingView.vue @@ -24,10 +24,37 @@ 各子账号消费与额度 + :default-sort="{ prop: 'consumed_total', order: 'descending' }" + row-key="id"> + + + - + + + - - - + + +