commit 555c86ce7688961412d6d15f046b90e698c2b615 Author: seaislee1209 Date: Thu Mar 19 13:03:30 2026 +0800 feat: initialize AirGate - Volcengine IAM sub-account management platform Backend (Django 4.2 + DRF): - Admin auth with SimpleJWT - Volcengine API client with HMAC-SHA256 signing - IAM user management (create/sync/import/disable/enable) - Billing query with pagination - Feishu webhook notifications (async) - APScheduler for periodic spending checks - AES-256 encrypted credential storage - API key auth for external system integration Frontend (Vue 3 + Element Plus): - Login page - Dashboard with stats overview - IAM user list with per-user threshold config - Billing view with spending progress bars - Alert history with type filtering - Settings page for global config and Volcengine account management Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dda2aa0 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# =========================================== +# AirGate 环境变量配置模板 +# 复制此文件为 .env 并填入真实值 +# =========================================== + +# Django 基础配置 +DJANGO_SECRET_KEY=change-me-to-a-random-string +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +# 数据库(本地开发不需要配置,默认用 SQLite) +# USE_MYSQL=false +# DB_HOST= +# DB_PORT=3306 +# DB_NAME=airgate +# DB_USER= +# DB_PASSWORD= + +# 火山引擎主账号密钥(必填,用于管理 IAM 子账号) +VOLC_ACCESS_KEY= +VOLC_SECRET_KEY= + +# 数据加密密钥(用于加密存储在数据库中的密钥) +# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +AIRGATE_ENCRYPTION_KEY= + +# 飞书机器人 Webhook(选填,也可在管理界面中配置) +FEISHU_WEBHOOK_URL= + +# AirGate API Key(供外部系统如 AirDrama 调用本系统 API 时使用) +AIRGATE_API_KEY=change-me-to-a-random-api-key + +# 消费监控检查间隔(秒,默认 3600 = 1小时) +MONITOR_INTERVAL=3600 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0986a1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Environment +.env +.env.local +*.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +venv/ +.venv/ + +# Django +backend/db.sqlite3 +backend/test_db.sqlite3 +backend/staticfiles/ +backend/media/ + +# Node +frontend/node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Research docs (keep in repo for reference) +# 火山引擎IAM子账号管控工具_深度研究报告.md diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..1bf72a2 --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import AdminUser + + +@admin.register(AdminUser) +class AdminUserAdmin(UserAdmin): + list_display = ('username', 'is_active', 'is_superuser', 'date_joined') diff --git a/backend/apps/accounts/apps.py b/backend/apps/accounts/apps.py new file mode 100644 index 0000000..add398e --- /dev/null +++ b/backend/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.accounts' + verbose_name = '管理员账户' diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..d329426 --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.21 on 2026-03-19 04:58 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='AdminUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': '管理员', + 'verbose_name_plural': '管理员', + 'db_table': 'airgate_admin_user', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/apps/accounts/migrations/__init__.py b/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..96994b6 --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,14 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class AdminUser(AbstractUser): + """AirGate 管理员用户""" + + class Meta: + verbose_name = '管理员' + verbose_name_plural = '管理员' + db_table = 'airgate_admin_user' + + def __str__(self): + return self.username diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..457be64 --- /dev/null +++ b/backend/apps/accounts/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + +class UserInfoSerializer(serializers.Serializer): + id = serializers.IntegerField() + username = serializers.CharField() + is_superuser = serializers.BooleanField() diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py new file mode 100644 index 0000000..60f6eb0 --- /dev/null +++ b/backend/apps/accounts/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('login/', views.login_view), + path('refresh/', views.refresh_view), + path('me/', views.me_view), +] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py new file mode 100644 index 0000000..708afc2 --- /dev/null +++ b/backend/apps/accounts/views.py @@ -0,0 +1,60 @@ +from django.contrib.auth import authenticate +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken + +from .serializers import LoginSerializer, UserInfoSerializer + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def login_view(request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = authenticate( + username=serializer.validated_data['username'], + password=serializer.validated_data['password'], + ) + if not user: + return Response( + {'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED, + ) + if not user.is_active: + return Response( + {'error': 'user_disabled', 'message': '账号已停用'}, + status=status.HTTP_403_FORBIDDEN, + ) + + refresh = RefreshToken.for_user(user) + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + 'user': UserInfoSerializer(user).data, + }) + + +@api_view(['POST']) +def refresh_view(request): + token = request.data.get('refresh') + if not token: + return Response( + {'error': 'missing_token', 'message': '缺少 refresh token'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + refresh = RefreshToken(token) + return Response({'access': str(refresh.access_token)}) + except Exception: + return Response( + {'error': 'invalid_token', 'message': 'token 无效或已过期'}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +@api_view(['GET']) +def me_view(request): + return Response(UserInfoSerializer(request.user).data) diff --git a/backend/apps/monitor/__init__.py b/backend/apps/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/monitor/admin.py b/backend/apps/monitor/admin.py new file mode 100644 index 0000000..089c851 --- /dev/null +++ b/backend/apps/monitor/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord + + +@admin.register(VolcAccount) +class VolcAccountAdmin(admin.ModelAdmin): + list_display = ('name', 'access_key_hint', 'is_active', 'updated_at') + + +@admin.register(IAMUser) +class IAMUserAdmin(admin.ModelAdmin): + list_display = ('username', 'display_name', 'status', 'monitor_enabled', + 'current_month_spending', 'alert_threshold', 'disable_threshold') + list_filter = ('status', 'monitor_enabled') + + +@admin.register(GlobalConfig) +class GlobalConfigAdmin(admin.ModelAdmin): + list_display = ('default_alert_threshold', 'default_disable_threshold', 'monitor_interval_seconds') + + +@admin.register(AlertRecord) +class AlertRecordAdmin(admin.ModelAdmin): + list_display = ('title', 'alert_type', 'spending_amount', 'notified', 'created_at') + list_filter = ('alert_type', 'notified') + + +@admin.register(SpendingRecord) +class SpendingRecordAdmin(admin.ModelAdmin): + list_display = ('iam_user', 'bill_period', 'amount', 'updated_at') diff --git a/backend/apps/monitor/apps.py b/backend/apps/monitor/apps.py new file mode 100644 index 0000000..04850e0 --- /dev/null +++ b/backend/apps/monitor/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + + +class MonitorConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.monitor' + verbose_name = 'IAM 监控管理' + + def ready(self): + from utils.scheduler import start_scheduler + import os + # Only start scheduler in the main process (not in migrate/shell/etc) + if os.environ.get('RUN_MAIN') == 'true' or os.environ.get('GUNICORN_RUNNING'): + start_scheduler() diff --git a/backend/apps/monitor/migrations/0001_initial.py b/backend/apps/monitor/migrations/0001_initial.py new file mode 100644 index 0000000..cd21aa9 --- /dev/null +++ b/backend/apps/monitor/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 4.2.21 on 2026-03-19 04:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='GlobalConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default_alert_threshold', models.DecimalField(decimal_places=2, default=1000, max_digits=12, verbose_name='默认告警阈值(元)')), + ('default_disable_threshold', models.DecimalField(decimal_places=2, default=5000, max_digits=12, verbose_name='默认停用阈值(元)')), + ('monitor_interval_seconds', models.IntegerField(default=3600, verbose_name='监控间隔(秒)')), + ('feishu_webhook_url', models.URLField(blank=True, max_length=500, verbose_name='飞书 Webhook URL')), + ('feishu_alert_mobiles', models.CharField(blank=True, max_length=500, verbose_name='飞书通知手机号(逗号分隔)')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '全局配置', + 'verbose_name_plural': '全局配置', + 'db_table': 'airgate_global_config', + }, + ), + migrations.CreateModel( + name='VolcAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='默认主账号', max_length=100, verbose_name='账号名称')), + ('access_key_enc', models.TextField(blank=True, verbose_name='AccessKey(加密)')), + ('secret_key_enc', models.TextField(blank=True, verbose_name='SecretKey(加密)')), + ('access_key_hint', models.CharField(blank=True, max_length=20, verbose_name='AK 提示(前4后4)')), + ('is_active', models.BooleanField(default=True, verbose_name='启用')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '火山主账号', + 'verbose_name_plural': '火山主账号', + 'db_table': 'airgate_volc_account', + }, + ), + migrations.CreateModel( + name='IAMUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(db_index=True, max_length=200, verbose_name='IAM 用户名')), + ('display_name', models.CharField(blank=True, max_length=200, verbose_name='显示名')), + ('user_id', models.CharField(blank=True, max_length=100, verbose_name='火山 UserID')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='邮箱')), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='手机号')), + ('project_name', models.CharField(blank=True, help_text='用于按项目维度追踪消费', max_length=200, verbose_name='关联项目名')), + ('status', models.CharField(choices=[('active', '正常'), ('disabled', '已停用'), ('unknown', '未知')], default='unknown', max_length=20, verbose_name='状态')), + ('access_key_ids', models.JSONField(blank=True, default=list, verbose_name='AccessKey ID 列表')), + ('monitor_enabled', models.BooleanField(default=True, verbose_name='启用消费监控')), + ('auto_disable_enabled', models.BooleanField(default=True, verbose_name='启用自动停用')), + ('alert_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='为空则使用全局默认值', max_digits=12, null=True, verbose_name='告警阈值(元)')), + ('disable_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='为空则使用全局默认值', max_digits=12, null=True, verbose_name='停用阈值(元)')), + ('current_month_spending', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='本月消费(元)')), + ('spending_updated_at', models.DateTimeField(blank=True, null=True, verbose_name='消费更新时间')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('volc_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iam_users', to='monitor.volcaccount')), + ], + options={ + 'verbose_name': 'IAM 子账号', + 'verbose_name_plural': 'IAM 子账号', + 'db_table': 'airgate_iam_user', + 'unique_together': {('volc_account', 'username')}, + }, + ), + migrations.CreateModel( + name='AlertRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alert_type', models.CharField(choices=[('warning', '告警'), ('disable', '自动停用'), ('error', '错误'), ('manual', '手动操作')], max_length=20, verbose_name='告警类型')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('content', models.TextField(verbose_name='详情')), + ('spending_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='触发时消费金额')), + ('threshold_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='触发阈值')), + ('notified', models.BooleanField(default=False, verbose_name='已通知')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('iam_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='monitor.iamuser')), + ], + options={ + 'verbose_name': '告警记录', + 'verbose_name_plural': '告警记录', + 'db_table': 'airgate_alert_record', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SpendingRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bill_period', models.CharField(db_index=True, max_length=7, verbose_name='账期 (YYYY-MM)')), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='消费金额(元)')), + ('detail', models.JSONField(blank=True, default=dict, verbose_name='消费明细')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='spending_records', to='monitor.iamuser')), + ], + options={ + 'verbose_name': '消费记录', + 'verbose_name_plural': '消费记录', + 'db_table': 'airgate_spending_record', + 'unique_together': {('iam_user', 'bill_period')}, + }, + ), + ] diff --git a/backend/apps/monitor/migrations/__init__.py b/backend/apps/monitor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py new file mode 100644 index 0000000..4bd0817 --- /dev/null +++ b/backend/apps/monitor/models.py @@ -0,0 +1,148 @@ +from django.db import models + + +class VolcAccount(models.Model): + """火山引擎主账号配置(加密存储)""" + name = models.CharField('账号名称', max_length=100, default='默认主账号') + access_key_enc = models.TextField('AccessKey(加密)', blank=True) + secret_key_enc = models.TextField('SecretKey(加密)', blank=True) + access_key_hint = models.CharField('AK 提示(前4后4)', max_length=20, blank=True) + is_active = models.BooleanField('启用', default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '火山主账号' + verbose_name_plural = '火山主账号' + db_table = 'airgate_volc_account' + + def __str__(self): + return f"{self.name} ({self.access_key_hint})" + + +class IAMUser(models.Model): + """受管理的 IAM 子账号""" + + class Status(models.TextChoices): + ACTIVE = 'active', '正常' + DISABLED = 'disabled', '已停用' + UNKNOWN = 'unknown', '未知' + + volc_account = models.ForeignKey(VolcAccount, on_delete=models.CASCADE, related_name='iam_users') + username = models.CharField('IAM 用户名', max_length=200, db_index=True) + display_name = models.CharField('显示名', max_length=200, blank=True) + 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) + access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) + + # Monitoring config + monitor_enabled = models.BooleanField('启用消费监控', default=True) + auto_disable_enabled = models.BooleanField('启用自动停用', default=True) + alert_threshold = models.DecimalField('告警阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True, + help_text='为空则使用全局默认值') + disable_threshold = models.DecimalField('停用阈值(元)', max_digits=12, decimal_places=2, null=True, blank=True, + help_text='为空则使用全局默认值') + + # Spending cache + current_month_spending = models.DecimalField('本月消费(元)', max_digits=12, decimal_places=2, default=0) + spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True) + + remark = models.TextField('备注', blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'IAM 子账号' + verbose_name_plural = 'IAM 子账号' + db_table = 'airgate_iam_user' + unique_together = [('volc_account', 'username')] + + def __str__(self): + return f"{self.display_name or self.username} ({self.status})" + + def get_alert_threshold(self): + if self.alert_threshold is not None: + return self.alert_threshold + config = GlobalConfig.get_solo() + return config.default_alert_threshold + + def get_disable_threshold(self): + if self.disable_threshold is not None: + return self.disable_threshold + config = GlobalConfig.get_solo() + return config.default_disable_threshold + + +class GlobalConfig(models.Model): + """全局配置(单例)""" + default_alert_threshold = models.DecimalField('默认告警阈值(元)', max_digits=12, decimal_places=2, default=1000) + default_disable_threshold = models.DecimalField('默认停用阈值(元)', max_digits=12, decimal_places=2, default=5000) + monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600) + feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True) + feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '全局配置' + verbose_name_plural = '全局配置' + db_table = 'airgate_global_config' + + @classmethod + def get_solo(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + def __str__(self): + return '全局配置' + + +class AlertRecord(models.Model): + """告警记录""" + + class AlertType(models.TextChoices): + WARNING = 'warning', '告警' + DISABLE = 'disable', '自动停用' + ERROR = 'error', '错误' + MANUAL = 'manual', '手动操作' + + iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='alerts', null=True, blank=True) + alert_type = models.CharField('告警类型', max_length=20, choices=AlertType.choices) + title = models.CharField('标题', max_length=200) + content = models.TextField('详情') + spending_amount = models.DecimalField('触发时消费金额', max_digits=12, decimal_places=2, null=True, blank=True) + threshold_amount = models.DecimalField('触发阈值', max_digits=12, decimal_places=2, null=True, blank=True) + notified = models.BooleanField('已通知', default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = '告警记录' + verbose_name_plural = '告警记录' + db_table = 'airgate_alert_record' + ordering = ['-created_at'] + + def __str__(self): + return f"[{self.alert_type}] {self.title}" + + +class SpendingRecord(models.Model): + """月度消费快照""" + iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records') + 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')] + + def __str__(self): + return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}" diff --git a/backend/apps/monitor/permissions.py b/backend/apps/monitor/permissions.py new file mode 100644 index 0000000..05e94c4 --- /dev/null +++ b/backend/apps/monitor/permissions.py @@ -0,0 +1,11 @@ +from rest_framework.permissions import BasePermission +from django.conf import settings + + +class IsAPIKeyAuth(BasePermission): + """允许通过 X-API-Key 头认证(供外部系统如 AirDrama 调用)""" + + def has_permission(self, request, view): + api_key = request.headers.get('X-API-Key', '') + expected = settings.AIRGATE_API_KEY + return bool(expected and api_key == expected) diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py new file mode 100644 index 0000000..9b941c5 --- /dev/null +++ b/backend/apps/monitor/serializers.py @@ -0,0 +1,107 @@ +from rest_framework import serializers +from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord + + +class VolcAccountSerializer(serializers.ModelSerializer): + class Meta: + model = VolcAccount + fields = ['id', 'name', 'access_key_hint', 'is_active', 'created_at', 'updated_at'] + read_only_fields = ['access_key_hint', 'created_at', 'updated_at'] + + +class VolcAccountCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100, default='默认主账号') + access_key = serializers.CharField(write_only=True) + secret_key = serializers.CharField(write_only=True) + + +class IAMUserSerializer(serializers.ModelSerializer): + effective_alert_threshold = serializers.SerializerMethodField() + effective_disable_threshold = serializers.SerializerMethodField() + + class Meta: + model = IAMUser + fields = [ + 'id', 'username', 'display_name', 'user_id', 'email', 'phone', + 'project_name', 'status', 'access_key_ids', + 'monitor_enabled', 'auto_disable_enabled', + 'alert_threshold', 'disable_threshold', + 'effective_alert_threshold', 'effective_disable_threshold', + 'current_month_spending', 'spending_updated_at', + 'remark', 'created_at', 'updated_at', + ] + read_only_fields = ['user_id', 'access_key_ids', 'status', + 'current_month_spending', 'spending_updated_at', + 'created_at', 'updated_at'] + + def get_effective_alert_threshold(self, obj): + return str(obj.get_alert_threshold()) + + def get_effective_disable_threshold(self, obj): + return str(obj.get_disable_threshold()) + + +class IAMUserCreateSerializer(serializers.Serializer): + username = serializers.CharField(max_length=200) + display_name = serializers.CharField(max_length=200, required=False, default='') + 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='') + alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, + required=False, allow_null=True) + disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, + required=False, allow_null=True) + + +class IAMUserImportSerializer(serializers.Serializer): + username = serializers.CharField(max_length=200, help_text='已存在的 IAM 用户名') + + +class IAMUserThresholdSerializer(serializers.Serializer): + alert_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, + required=False, allow_null=True) + disable_threshold = serializers.DecimalField(max_digits=12, decimal_places=2, + required=False, allow_null=True) + monitor_enabled = serializers.BooleanField(required=False) + auto_disable_enabled = serializers.BooleanField(required=False) + + +class GlobalConfigSerializer(serializers.ModelSerializer): + class Meta: + model = GlobalConfig + fields = [ + 'default_alert_threshold', 'default_disable_threshold', + 'monitor_interval_seconds', + 'feishu_webhook_url', 'feishu_alert_mobiles', + 'updated_at', + ] + read_only_fields = ['updated_at'] + + +class AlertRecordSerializer(serializers.ModelSerializer): + iam_username = serializers.CharField(source='iam_user.username', default='') + + class Meta: + model = AlertRecord + fields = [ + 'id', 'iam_user', 'iam_username', 'alert_type', 'title', 'content', + 'spending_amount', 'threshold_amount', 'notified', 'created_at', + ] + + +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() + disabled_users = serializers.IntegerField() + monitored_users = serializers.IntegerField() + total_spending = serializers.DecimalField(max_digits=12, decimal_places=2) + recent_alerts = AlertRecordSerializer(many=True) diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py new file mode 100644 index 0000000..5e894c0 --- /dev/null +++ b/backend/apps/monitor/urls.py @@ -0,0 +1,33 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # Dashboard + path('dashboard/', views.dashboard_view), + + # Volcengine account management + path('volc-accounts/', views.volc_account_view), + path('volc-accounts//', views.volc_account_detail_view), + path('volc-accounts//test/', views.volc_account_test_view), + + # IAM user management + path('iam-users/', views.iam_user_list_view), + path('iam-users/sync/', views.iam_user_sync_view), + path('iam-users/import/', views.iam_user_import_view), + path('iam-users//', views.iam_user_detail_view), + path('iam-users//update/', views.iam_user_update_view), + path('iam-users//disable/', views.iam_user_disable_view), + path('iam-users//enable/', views.iam_user_enable_view), + path('iam-users//policies/', views.iam_user_policies_view), + + # Billing + path('billing/overview/', views.spending_overview_view), + path('billing/refresh/', views.spending_refresh_view), + path('billing/balance/', views.balance_view), + + # Global config + path('config/', views.global_config_view), + + # Alerts + path('alerts/', views.alert_list_view), +] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py new file mode 100644 index 0000000..5ff6e78 --- /dev/null +++ b/backend/apps/monitor/views.py @@ -0,0 +1,419 @@ +"""AirGate 核心 API 视图""" + +import logging +from datetime import datetime +from decimal import Decimal + +from django.db.models import Sum +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from utils.crypto import encrypt, decrypt, make_hint +from utils.iam_service import IAMService +from utils.billing_service import BillingService +from utils.volcengine_client import VolcengineAPIError + +from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord +from .serializers import ( + VolcAccountSerializer, VolcAccountCreateSerializer, + IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, + IAMUserThresholdSerializer, + GlobalConfigSerializer, + AlertRecordSerializer, + DashboardSerializer, +) + +logger = logging.getLogger(__name__) + + +def _get_volc_account(volc_id=None): + """获取主账号,解密密钥""" + if volc_id: + account = VolcAccount.objects.get(pk=volc_id) + else: + account = VolcAccount.objects.filter(is_active=True).first() + if not account: + return None, '', '' + ak = decrypt(account.access_key_enc) + sk = decrypt(account.secret_key_enc) + return account, ak, sk + + +# ==================== Dashboard ==================== + +@api_view(['GET']) +def dashboard_view(request): + total = IAMUser.objects.count() + active = IAMUser.objects.filter(status=IAMUser.Status.ACTIVE).count() + disabled = IAMUser.objects.filter(status=IAMUser.Status.DISABLED).count() + monitored = IAMUser.objects.filter(monitor_enabled=True).count() + total_spending = IAMUser.objects.aggregate( + total=Sum('current_month_spending'))['total'] or Decimal('0') + recent_alerts = AlertRecord.objects.all()[:10] + + data = { + 'total_users': total, + 'active_users': active, + 'disabled_users': disabled, + 'monitored_users': monitored, + 'total_spending': total_spending, + 'recent_alerts': AlertRecordSerializer(recent_alerts, many=True).data, + } + return Response(data) + + +# ==================== Volcengine Account ==================== + +@api_view(['GET', 'POST']) +def volc_account_view(request): + if request.method == 'GET': + accounts = VolcAccount.objects.all() + return Response(VolcAccountSerializer(accounts, many=True).data) + + serializer = VolcAccountCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data + + account = VolcAccount.objects.create( + name=d['name'], + access_key_enc=encrypt(d['access_key']), + secret_key_enc=encrypt(d['secret_key']), + access_key_hint=make_hint(d['access_key']), + ) + return Response(VolcAccountSerializer(account).data, status=status.HTTP_201_CREATED) + + +@api_view(['PUT', 'DELETE']) +def volc_account_detail_view(request, pk): + try: + account = VolcAccount.objects.get(pk=pk) + except VolcAccount.DoesNotExist: + return Response({'error': 'not_found', 'message': '主账号不存在'}, + status=status.HTTP_404_NOT_FOUND) + + if request.method == 'DELETE': + account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + # PUT: update + name = request.data.get('name') + if name: + account.name = name + ak = request.data.get('access_key') + sk = request.data.get('secret_key') + if ak: + account.access_key_enc = encrypt(ak) + account.access_key_hint = make_hint(ak) + if sk: + account.secret_key_enc = encrypt(sk) + account.save() + return Response(VolcAccountSerializer(account).data) + + +@api_view(['POST']) +def volc_account_test_view(request, pk): + """测试主账号密钥是否有效""" + try: + account = VolcAccount.objects.get(pk=pk) + except VolcAccount.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + ak = decrypt(account.access_key_enc) + sk = decrypt(account.secret_key_enc) + try: + svc = IAMService(ak, sk) + svc.list_users(limit=1) + return Response({'status': 'ok', 'message': '密钥验证成功'}) + except VolcengineAPIError as e: + return Response({'status': 'error', 'message': str(e)}, + status=status.HTTP_400_BAD_REQUEST) + + +# ==================== IAM Users ==================== + +@api_view(['GET']) +def iam_user_list_view(request): + users = IAMUser.objects.select_related('volc_account').all() + status_filter = request.query_params.get('status') + if status_filter: + users = users.filter(status=status_filter) + return Response(IAMUserSerializer(users, many=True).data) + + +@api_view(['POST']) +def iam_user_sync_view(request): + """从火山引擎同步所有已有 IAM 用户""" + 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) + imported = [] + offset = 0 + + while True: + try: + resp = svc.list_users(limit=100, offset=offset) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + users = resp.get("Result", {}).get("UserMetadata", []) + if not users: + break + + for u in users: + username = u.get("UserName", "") + obj, created = IAMUser.objects.update_or_create( + volc_account=account, + username=username, + defaults={ + 'display_name': u.get("DisplayName", ""), + 'user_id': u.get("UserId", ""), + 'email': u.get("Email", ""), + 'phone': u.get("MobilePhone", ""), + }, + ) + if created: + imported.append(username) + + # Sync access keys + try: + keys = svc.list_access_keys(username) + obj.access_key_ids = [k["AccessKeyId"] for k in keys] + except Exception: + pass + + # Sync login status + try: + profile = svc.get_login_profile(username) + login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True) + obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED + except Exception: + obj.status = IAMUser.Status.UNKNOWN + + obj.save() + + offset += 100 + total = resp.get("Result", {}).get("Total", 0) + if offset >= total: + break + + total_count = IAMUser.objects.filter(volc_account=account).count() + return Response({ + 'message': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个', + 'imported': imported, + 'total': total_count, + }) + + +@api_view(['POST']) +def iam_user_import_view(request): + """导入指定的已有 IAM 用户""" + serializer = IAMUserImportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + + 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) + try: + resp = svc.get_user(username) + except VolcengineAPIError as e: + return Response({'error': 'user_not_found', 'message': f'火山引擎未找到用户: {e}'}, + status=status.HTTP_404_NOT_FOUND) + + u = resp.get("Result", {}).get("User", {}) + obj, created = IAMUser.objects.update_or_create( + volc_account=account, + username=username, + defaults={ + 'display_name': u.get("DisplayName", ""), + 'user_id': u.get("UserId", ""), + 'email': u.get("Email", ""), + 'phone': u.get("MobilePhone", ""), + }, + ) + return Response({ + 'message': '导入成功' if created else '用户已存在,已更新信息', + 'user': IAMUserSerializer(obj).data, + }) + + +@api_view(['GET']) +def iam_user_detail_view(request, pk): + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + return Response(IAMUserSerializer(user).data) + + +@api_view(['PUT']) +def iam_user_update_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 = IAMUserThresholdSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + for field, value in serializer.validated_data.items(): + setattr(user, field, value) + user.save() + return Response(IAMUserSerializer(user).data) + + +@api_view(['POST']) +def iam_user_disable_view(request, pk): + """停用子账号""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + 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.disable_user(user.username) + user.status = IAMUser.Status.DISABLED + user.save(update_fields=['status']) + AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"手动停用子账号 {user.username}", + content=f"操作人: {request.user.username}", + ) + return Response({'message': f'用户 {user.username} 已停用'}) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +@api_view(['POST']) +def iam_user_enable_view(request, pk): + """恢复子账号""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + 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.enable_user(user.username) + user.status = IAMUser.Status.ACTIVE + user.save(update_fields=['status']) + AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"手动恢复子账号 {user.username}", + content=f"操作人: {request.user.username}", + ) + return Response({'message': f'用户 {user.username} 已恢复'}) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +@api_view(['GET']) +def iam_user_policies_view(request, pk): + """查看子账号的权限策略""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + 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: + resp = svc.list_attached_user_policies(user.username) + policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) + return Response({'policies': policies}) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +# ==================== Billing ==================== + +@api_view(['GET']) +def spending_overview_view(request): + """消费总览""" + bill_period = request.query_params.get('period', datetime.now().strftime("%Y-%m")) + users = IAMUser.objects.all().order_by('-current_month_spending') + return Response({ + 'period': bill_period, + 'users': IAMUserSerializer(users, many=True).data, + }) + + +@api_view(['POST']) +def spending_refresh_view(request): + """手动刷新消费数据""" + from utils.scheduler import check_spending + try: + check_spending() + return Response({'message': '消费数据刷新完成'}) + except Exception as e: + return Response({'error': 'refresh_failed', 'message': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['GET']) +def balance_view(request): + """查询主账号余额""" + account, ak, sk = _get_volc_account() + if not ak: + return Response({'error': 'no_account', 'message': '请先配置火山主账号'}, + status=status.HTTP_400_BAD_REQUEST) + try: + billing = BillingService(ak, sk) + result = billing.get_balance() + return Response(result) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +# ==================== Global Config ==================== + +@api_view(['GET', 'PUT']) +def global_config_view(request): + config = GlobalConfig.get_solo() + if request.method == 'GET': + return Response(GlobalConfigSerializer(config).data) + + serializer = GlobalConfigSerializer(config, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +# ==================== Alerts ==================== + +@api_view(['GET']) +def alert_list_view(request): + alerts = AlertRecord.objects.select_related('iam_user').all() + alert_type = request.query_params.get('type') + if alert_type: + alerts = alerts.filter(alert_type=alert_type) + limit = int(request.query_params.get('limit', 50)) + return Response(AlertRecordSerializer(alerts[:limit], many=True).data) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..8999549 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,147 @@ +import os +from pathlib import Path +from datetime import timedelta + +BASE_DIR = Path(__file__).resolve().parent.parent + +# --- Core --- +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key-change-in-production') +DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes') +ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,0.0.0.0').split(',') + +# --- Apps --- +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Third party + 'rest_framework', + 'corsheaders', + # Local apps + 'apps.accounts', + 'apps.monitor', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# --- Database --- +if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': os.environ.get('DB_NAME', 'airgate'), + 'USER': os.environ.get('DB_USER', 'airgate'), + 'PASSWORD': os.environ.get('DB_PASSWORD', ''), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', '3306'), + 'OPTIONS': { + 'charset': 'utf8mb4', + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +# --- Auth --- +AUTH_USER_MODEL = 'accounts.AdminUser' + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, +] + +# --- JWT --- +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '30/minute', + 'user': '120/minute', + }, +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# --- CORS --- +CORS_ALLOWED_ORIGINS = os.environ.get( + 'CORS_ALLOWED_ORIGINS', + 'http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173' +).split(',') +CORS_ALLOW_CREDENTIALS = True + +# --- i18n --- +LANGUAGE_CODE = 'zh-hans' +TIME_ZONE = 'Asia/Shanghai' +USE_I18N = True +USE_TZ = True + +# --- Static --- +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# --- Volcengine --- +VOLC_ACCESS_KEY = os.environ.get('VOLC_ACCESS_KEY', '') +VOLC_SECRET_KEY = os.environ.get('VOLC_SECRET_KEY', '') + +# --- Encryption --- +AIRGATE_ENCRYPTION_KEY = os.environ.get('AIRGATE_ENCRYPTION_KEY', '') + +# --- Feishu --- +FEISHU_WEBHOOK_URL = os.environ.get('FEISHU_WEBHOOK_URL', '') + +# --- API Key (for external systems like AirDrama) --- +AIRGATE_API_KEY = os.environ.get('AIRGATE_API_KEY', '') + +# --- Monitor --- +MONITOR_INTERVAL = int(os.environ.get('MONITOR_INTERVAL', '3600')) diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..5d3a126 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.urls import path, include +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +@api_view(['GET']) +@permission_classes([AllowAny]) +def healthz(request): + return Response({'status': 'ok'}) + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('healthz/', healthz), + path('api/v1/auth/', include('apps.accounts.urls')), + path('api/v1/', include('apps.monitor.urls')), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..6debfd2 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,12 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +env_path = Path(__file__).resolve().parent.parent.parent / '.env' +if env_path.exists(): + load_dotenv(env_path) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..32aaf1c --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys +from pathlib import Path + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + + # Load .env from project root (one level up from backend/) + from dotenv import load_dotenv + env_path = Path(__file__).resolve().parent.parent / '.env' + if env_path.exists(): + load_dotenv(env_path) + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7d258cb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +Django==4.2.21 +djangorestframework==3.15.2 +djangorestframework-simplejwt==5.4.0 +django-cors-headers==4.7.0 +cryptography==44.0.2 +requests==2.32.3 +APScheduler==3.11.0 +python-dotenv==1.1.0 +gunicorn==23.0.0 diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/utils/billing_service.py b/backend/utils/billing_service.py new file mode 100644 index 0000000..20d3fcf --- /dev/null +++ b/backend/utils/billing_service.py @@ -0,0 +1,59 @@ +"""消费查询服务""" + +import logging +from decimal import Decimal +from .volcengine_client import get_billing_client + +logger = logging.getLogger(__name__) + + +class BillingService: + """封装火山引擎 Billing API""" + + def __init__(self, ak: str, sk: str): + self.client = get_billing_client(ak, sk) + + def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal: + """查询指定项目的消费总额(带分页)""" + total = Decimal("0") + offset = 0 + page_size = 300 + + while True: + params = { + "BillPeriod": bill_period, + "Limit": str(page_size), + "Offset": str(offset), + "GroupTerm": "0", + "GroupPeriod": "0", + "NeedRecordNum": "1", + } + result = self.client.call("ListBillDetail", params) + items = result.get("Result", {}).get("List", []) + record_num = int(result.get("Result", {}).get("Total", 0)) + + for item in items: + if project_name and item.get("Project") != project_name: + continue + amount = item.get("PayableAmount", "0") + total += Decimal(str(amount)) + + offset += page_size + if offset >= record_num or not items: + break + + return total + + def get_bill_overview(self, bill_period: str) -> dict: + """获取账单总览(按产品维度)""" + result = self.client.call("ListBillOverviewByProd", { + "BillPeriod": bill_period, + "Limit": "100", + "NeedRecordNum": "1", + }) + return result.get("Result", {}) + + def get_balance(self) -> dict: + """查询主账号余额""" + result = self.client.call("QueryBalanceAcct") + return result.get("Result", {}) diff --git a/backend/utils/crypto.py b/backend/utils/crypto.py new file mode 100644 index 0000000..3f5fc09 --- /dev/null +++ b/backend/utils/crypto.py @@ -0,0 +1,56 @@ +"""AES 加密/解密工具,用于安全存储火山引擎密钥""" + +import logging +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings + +logger = logging.getLogger(__name__) + +_fernet = None + + +def _get_fernet(): + global _fernet + if _fernet is not None: + return _fernet + + key = settings.AIRGATE_ENCRYPTION_KEY + if not key: + logger.warning("AIRGATE_ENCRYPTION_KEY 未设置,密钥将以明文存储!") + return None + + try: + _fernet = Fernet(key.encode() if isinstance(key, str) else key) + return _fernet + except Exception as e: + logger.error(f"加密密钥格式错误: {e}") + return None + + +def encrypt(plaintext: str) -> str: + if not plaintext: + return '' + f = _get_fernet() + if f is None: + return plaintext + return f.encrypt(plaintext.encode()).decode() + + +def decrypt(ciphertext: str) -> str: + if not ciphertext: + return '' + f = _get_fernet() + if f is None: + return ciphertext + try: + return f.decrypt(ciphertext.encode()).decode() + except InvalidToken: + logger.error("解密失败:密文无效或加密密钥已变更") + return '' + + +def make_hint(ak: str) -> str: + """生成 AK 脱敏提示,如 AKLT****3A""" + if len(ak) <= 8: + return '****' + return f"{ak[:4]}****{ak[-4:]}" diff --git a/backend/utils/feishu.py b/backend/utils/feishu.py new file mode 100644 index 0000000..ad11b0d --- /dev/null +++ b/backend/utils/feishu.py @@ -0,0 +1,42 @@ +"""飞书机器人通知""" + +import logging +import threading +import requests + +logger = logging.getLogger(__name__) + + +def send_feishu_alert(webhook_url: str, title: str, content: str, + template: str = "red"): + """发送飞书卡片消息(非阻塞)""" + if not webhook_url: + logger.warning(f"飞书 Webhook 未配置,跳过通知: {title}") + return + + def _send(): + payload = { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": title}, + "template": template, + }, + "elements": [ + { + "tag": "div", + "text": {"tag": "lark_md", "content": content}, + } + ], + }, + } + try: + resp = requests.post(webhook_url, json=payload, timeout=10) + resp.raise_for_status() + logger.info(f"飞书通知已发送: {title}") + except Exception as e: + logger.error(f"飞书通知发送失败: {e}") + + thread = threading.Thread(target=_send, daemon=True) + thread.start() diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py new file mode 100644 index 0000000..95612c4 --- /dev/null +++ b/backend/utils/iam_service.py @@ -0,0 +1,120 @@ +"""IAM 子账号管理服务""" + +import logging +from .volcengine_client import get_iam_client, VolcengineAPIError + +logger = logging.getLogger(__name__) + + +class IAMService: + """封装火山引擎 IAM API 操作""" + + def __init__(self, ak: str, sk: str): + self.client = get_iam_client(ak, sk) + + def list_users(self, limit=100, offset=0) -> dict: + return self.client.call("ListUsers", {"Limit": str(limit), "Offset": str(offset)}) + + def get_user(self, username: str) -> dict: + return self.client.call("GetUser", {"UserName": username}) + + def create_user(self, username: str, display_name: str = "", email: str = "", + phone: str = "") -> dict: + params = {"UserName": username} + if display_name: + params["DisplayName"] = display_name + if email: + params["Email"] = email + if phone: + params["MobilePhone"] = phone + return self.client.call("CreateUser", params) + + def create_login_profile(self, username: str, password: str, + login_allowed: bool = True, must_reset: bool = True) -> dict: + return self.client.call("CreateLoginProfile", { + "UserName": username, + "Password": password, + "LoginAllowed": str(login_allowed).lower(), + "PasswordResetRequired": str(must_reset).lower(), + }) + + def update_login_allowed(self, username: str, allowed: bool) -> dict: + return self.client.call("UpdateLoginProfile", { + "UserName": username, + "LoginAllowed": str(allowed).lower(), + }) + + def get_login_profile(self, username: str) -> dict: + return self.client.call("GetLoginProfile", {"UserName": username}) + + def list_access_keys(self, username: str) -> list: + resp = self.client.call("ListAccessKeys", {"UserName": username}) + return resp.get("Result", {}).get("AccessKeyMetadata", []) + + def update_access_key(self, ak_id: str, status: str, username: str = "") -> dict: + params = {"AccessKeyId": ak_id, "Status": status} + if username: + params["UserName"] = username + return self.client.call("UpdateAccessKey", params) + + def create_access_key(self, username: str) -> dict: + return self.client.call("CreateAccessKey", {"UserName": username}) + + def attach_user_policy(self, username: str, policy_name: str, + policy_type: str = "System") -> dict: + return self.client.call("AttachUserPolicy", { + "UserName": username, + "PolicyName": policy_name, + "PolicyType": policy_type, + }) + + def detach_user_policy(self, username: str, policy_name: str, + policy_type: str = "System") -> dict: + return self.client.call("DetachUserPolicy", { + "UserName": username, + "PolicyName": policy_name, + "PolicyType": policy_type, + }) + + def list_attached_user_policies(self, username: str) -> dict: + return self.client.call("ListAttachedUserPolicies", {"UserName": username}) + + def disable_user(self, username: str): + """完全停用用户:停控制台 + 停所有 AccessKey""" + errors = [] + + try: + self.update_login_allowed(username, False) + except VolcengineAPIError as e: + errors.append(f"停用控制台失败: {e}") + + try: + keys = self.list_access_keys(username) + for key in keys: + if key.get("Status") == "active": + self.update_access_key(key["AccessKeyId"], "inactive", username) + except VolcengineAPIError as e: + errors.append(f"停用密钥失败: {e}") + + if errors: + raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors)) + + def enable_user(self, username: str): + """恢复用户:恢复控制台 + 恢复所有 AccessKey""" + errors = [] + + try: + self.update_login_allowed(username, True) + except VolcengineAPIError as e: + errors.append(f"恢复控制台失败: {e}") + + try: + keys = self.list_access_keys(username) + for key in keys: + if key.get("Status") == "inactive": + self.update_access_key(key["AccessKeyId"], "active", username) + except VolcengineAPIError as e: + errors.append(f"恢复密钥失败: {e}") + + if errors: + raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors)) diff --git a/backend/utils/scheduler.py b/backend/utils/scheduler.py new file mode 100644 index 0000000..f1a7d6b --- /dev/null +++ b/backend/utils/scheduler.py @@ -0,0 +1,144 @@ +"""定时消费监控任务""" + +import logging +from datetime import datetime +from decimal import Decimal + +logger = logging.getLogger(__name__) + +_scheduler_started = False + + +def check_spending(): + """定时检查所有子账号消费""" + from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord + from utils.crypto import decrypt + from utils.billing_service import BillingService + from utils.iam_service import IAMService + from utils.feishu import send_feishu_alert + + bill_period = datetime.now().strftime("%Y-%m") + config = GlobalConfig.get_solo() + + for volc_account in VolcAccount.objects.filter(is_active=True): + ak = decrypt(volc_account.access_key_enc) + sk = decrypt(volc_account.secret_key_enc) + if not ak or not sk: + logger.warning(f"主账号 {volc_account.name} 密钥为空,跳过") + continue + + billing = BillingService(ak, sk) + iam_svc = IAMService(ak, sk) + + users = IAMUser.objects.filter( + volc_account=volc_account, + monitor_enabled=True, + ).exclude(status=IAMUser.Status.DISABLED) + + for user in users: + try: + spending = billing.get_spending_by_project( + bill_period, user.project_name or None + ) + user.current_month_spending = spending + user.spending_updated_at = datetime.now() + user.save(update_fields=['current_month_spending', 'spending_updated_at']) + + disable_threshold = user.get_disable_threshold() + alert_threshold = user.get_alert_threshold() + + # Check disable threshold + if (user.auto_disable_enabled + and disable_threshold + and spending >= disable_threshold): + + already_disabled = AlertRecord.objects.filter( + iam_user=user, + alert_type=AlertRecord.AlertType.DISABLE, + created_at__month=datetime.now().month, + created_at__year=datetime.now().year, + ).exists() + + if not already_disabled: + try: + iam_svc.disable_user(user.username) + user.status = IAMUser.Status.DISABLED + user.save(update_fields=['status']) + except Exception as e: + logger.error(f"停用用户 {user.username} 失败: {e}") + + alert = AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.DISABLE, + title=f"子账号 {user.username} 已自动停用", + content=f"本月消费 ¥{spending:.2f},达到停用阈值 ¥{disable_threshold:.2f}", + spending_amount=spending, + threshold_amount=disable_threshold, + ) + webhook = config.feishu_webhook_url + send_feishu_alert( + webhook, + "🚨 子账号已自动停用", + f"**用户**: {user.username}\n" + f"**消费**: ¥{spending:.2f}\n" + f"**阈值**: ¥{disable_threshold:.2f}\n" + f"如需恢复,请在 AirGate 管理后台操作。", + template="red", + ) + alert.notified = True + alert.save(update_fields=['notified']) + + # Check alert threshold + elif alert_threshold and spending >= alert_threshold: + already_alerted = AlertRecord.objects.filter( + iam_user=user, + alert_type=AlertRecord.AlertType.WARNING, + created_at__month=datetime.now().month, + created_at__year=datetime.now().year, + ).exists() + + if not already_alerted: + alert = AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.WARNING, + title=f"子账号 {user.username} 消费告警", + content=f"本月消费 ¥{spending:.2f},达到告警阈值 ¥{alert_threshold:.2f}", + spending_amount=spending, + threshold_amount=alert_threshold, + ) + webhook = config.feishu_webhook_url + send_feishu_alert( + webhook, + "⚠️ 子账号消费告警", + f"**用户**: {user.username}\n" + f"**消费**: ¥{spending:.2f}\n" + f"**告警阈值**: ¥{alert_threshold:.2f}\n" + f"**停用阈值**: ¥{disable_threshold:.2f}", + template="orange", + ) + alert.notified = True + alert.save(update_fields=['notified']) + + except Exception as e: + logger.error(f"检查用户 {user.username} 消费失败: {e}") + + +def start_scheduler(): + """启动定时任务""" + global _scheduler_started + if _scheduler_started: + return + _scheduler_started = True + + try: + from apscheduler.schedulers.background import BackgroundScheduler + from django.conf import settings + + scheduler = BackgroundScheduler() + interval = getattr(settings, 'MONITOR_INTERVAL', 3600) + scheduler.add_job(check_spending, 'interval', seconds=interval, + id='check_spending', replace_existing=True) + scheduler.start() + logger.info(f"消费监控定时任务已启动,间隔 {interval} 秒") + except Exception as e: + logger.error(f"启动定时任务失败: {e}") diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py new file mode 100644 index 0000000..723e125 --- /dev/null +++ b/backend/utils/volcengine_client.py @@ -0,0 +1,115 @@ +"""火山引擎 Open API 客户端(HMAC-SHA256 签名)""" + +import datetime +import hashlib +import hmac +import logging +from urllib.parse import quote + +import requests + +logger = logging.getLogger(__name__) + + +class VolcengineAPIError(Exception): + def __init__(self, action: str, code: str, message: str): + self.action = action + self.code = code + super().__init__(f"[{action}] {code}: {message}") + + +class VolcengineClient: + """火山引擎 API 客户端""" + + def __init__(self, ak: str, sk: str, service: str, host: str, + region: str = "cn-north-1", version: str = "2018-01-01"): + self.ak = ak + self.sk = sk + self.service = service + self.host = host + self.region = region + self.version = version + + def _norm_query(self, params: dict) -> str: + query = "" + for key in sorted(params.keys()): + if isinstance(params[key], list): + for v in params[key]: + query += quote(key, safe="-_.~") + "=" + quote(str(v), safe="-_.~") + "&" + else: + query += quote(key, safe="-_.~") + "=" + quote(str(params[key]), safe="-_.~") + "&" + return query[:-1].replace("+", "%20") if query else "" + + def _hmac_sha256(self, key: bytes, content: str) -> bytes: + return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest() + + def _hash_sha256(self, content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + def call(self, action: str, params: dict = None, body: str = "") -> dict: + params = params or {} + now = datetime.datetime.now(datetime.timezone.utc) + x_date = now.strftime("%Y%m%dT%H%M%SZ") + short_date = x_date[:8] + + x_content_sha256 = self._hash_sha256(body) + all_params = {"Action": action, "Version": self.version, **params} + + signed_headers_str = "content-type;host;x-content-sha256;x-date" + canonical_headers = ( + f"content-type:application/x-www-form-urlencoded\n" + f"host:{self.host}\n" + f"x-content-sha256:{x_content_sha256}\n" + f"x-date:{x_date}" + ) + query_string = self._norm_query(all_params) + canonical_request = "\n".join([ + "GET", "/", query_string, + canonical_headers, "", signed_headers_str, x_content_sha256 + ]) + + credential_scope = f"{short_date}/{self.region}/{self.service}/request" + string_to_sign = "\n".join([ + "HMAC-SHA256", x_date, credential_scope, + self._hash_sha256(canonical_request) + ]) + + k_date = self._hmac_sha256(self.sk.encode("utf-8"), short_date) + k_region = self._hmac_sha256(k_date, self.region) + k_service = self._hmac_sha256(k_region, self.service) + k_signing = self._hmac_sha256(k_service, "request") + signature = self._hmac_sha256(k_signing, string_to_sign).hex() + + headers = { + "Host": self.host, + "X-Date": x_date, + "X-Content-Sha256": x_content_sha256, + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": ( + f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, " + f"SignedHeaders={signed_headers_str}, Signature={signature}" + ), + } + + url = f"https://{self.host}/?{query_string}" + try: + r = requests.get(url, headers=headers, timeout=30) + resp = r.json() + except Exception as e: + raise VolcengineAPIError(action, "NetworkError", str(e)) + + error = resp.get("ResponseMetadata", {}).get("Error") + if error: + raise VolcengineAPIError( + action, error.get("Code", "Unknown"), error.get("Message", "") + ) + return resp + + +def get_iam_client(ak: str, sk: str) -> VolcengineClient: + return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com") + + +def get_billing_client(ak: str, sk: str) -> VolcengineClient: + return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com", + version="2022-01-01") diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8866e76 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1721 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.6", + "element-plus": "^2.13.5", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "vite": "^8.0.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.5", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.5.tgz", + "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8adbf85 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.6", + "element-plus": "^2.13.5", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "vite": "^8.0.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..215db77 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,30 @@ +import axios from 'axios' +import { useAuthStore } from '../stores/auth' +import router from '../router' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8100', + timeout: 30000, +}) + +api.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.token) { + config.headers.Authorization = `Bearer ${auth.token}` + } + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + router.push('/login') + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..60d534c --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..0a82b0d --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import router from './router' +import App from './App.vue' +import './style.css' + +const app = createApp(App) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..7fa91ca --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,36 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/', + component: () => import('../layouts/MainLayout.vue'), + children: [ + { path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') }, + { path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') }, + { path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') }, + { path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') }, + { path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + if (!to.meta.public && !auth.isLoggedIn) { + return '/login' + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..fff08d1 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('airgate_token') || '') + const refreshToken = ref(localStorage.getItem('airgate_refresh') || '') + const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null')) + + const isLoggedIn = computed(() => !!token.value) + + function setAuth(data) { + token.value = data.access + refreshToken.value = data.refresh + user.value = data.user + localStorage.setItem('airgate_token', data.access) + localStorage.setItem('airgate_refresh', data.refresh) + localStorage.setItem('airgate_user', JSON.stringify(data.user)) + } + + function logout() { + token.value = '' + refreshToken.value = '' + user.value = null + localStorage.removeItem('airgate_token') + localStorage.removeItem('airgate_refresh') + localStorage.removeItem('airgate_user') + } + + return { token, refreshToken, user, isLoggedIn, setAuth, logout } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..fadb5b7 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,16 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + background: #f5f7fa; + color: #333; +} + +#app { + width: 100%; + min-height: 100vh; +} diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..7a04860 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/views/alerts/AlertList.vue b/frontend/src/views/alerts/AlertList.vue new file mode 100644 index 0000000..d2e7ae0 --- /dev/null +++ b/frontend/src/views/alerts/AlertList.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/src/views/billing/BillingView.vue b/frontend/src/views/billing/BillingView.vue new file mode 100644 index 0000000..9eab126 --- /dev/null +++ b/frontend/src/views/billing/BillingView.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue new file mode 100644 index 0000000..c255c25 --- /dev/null +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue new file mode 100644 index 0000000..4ccdfbe --- /dev/null +++ b/frontend/src/views/iam/IAMUserList.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue new file mode 100644 index 0000000..9c13ec6 --- /dev/null +++ b/frontend/src/views/settings/SettingsView.vue @@ -0,0 +1,171 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..84fe301 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8100', + changeOrigin: true, + }, + }, + }, +}) diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md new file mode 100644 index 0000000..9fc2881 --- /dev/null +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -0,0 +1,1218 @@ +# 火山引擎 IAM 子账号管控工具 -- 深度研究报告 + +> 研究日期:2026-03-19 +> 目标:通过火山引擎 Open API,实现对 IAM 子账号的全面管控,包括权限隔离、消费监控、告警、自动停用等功能。 + +--- + +## 目录 + +1. [整体架构方案](#1-整体架构方案) +2. [API 认证与签名机制](#2-api-认证与签名机制) +3. [IAM 用户管理 API](#3-iam-用户管理-api) +4. [权限策略管理](#4-权限策略管理) +5. [API 密钥管理](#5-api-密钥管理) +6. [计费与消费查询 API](#6-计费与消费查询-api) +7. [预算与告警机制](#7-预算与告警机制) +8. [子账号自动停用/恢复方案](#8-子账号自动停用恢复方案) +9. [项目管理与资源隔离](#9-项目管理与资源隔离) +10. [SDK 与工具链](#10-sdk-与工具链) +11. [可执行实施方案](#11-可执行实施方案) +12. [限制与注意事项](#12-限制与注意事项) +13. [参考文档](#13-参考文档) + +--- + +## 1. 整体架构方案 + +### 1.1 核心需求 + +| 需求 | 实现方式 | 可行性 | +|------|----------|--------| +| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** | +| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** | +| 子账号能看到自己的账单 | 通过项目 + 标签维度,主账号代查并展示 | **部分可行**(见下方说明)| +| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** | +| 消费达到阈值发告警 | 定时轮询 Billing API + Webhook/飞书通知 | **完全可行** | +| 消费达到阈值自动停用 | 轮询消费 + 调用 IAM API 停用用户和密钥 | **完全可行** | +| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** | + +### 1.2 架构图 + +``` +┌──────────────────────────────────────────────────────┐ +│ 管控工具 (后端服务) │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ +│ │ IAM管理 │ │ 消费监控 │ │ 告警引擎 │ │ +│ │ 模块 │ │ 模块 │ │ 模块 │ │ +│ └────┬─────┘ └────┬─────┘ └────┬───────┘ │ +│ │ │ │ │ +│ ┌────▼─────────────▼─────────────▼───────┐ │ +│ │ 火山引擎 Open API 调用层 │ │ +│ │ (HMAC-SHA256 签名认证) │ │ +│ └────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ + │ │ │ + ┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐ + │ IAM API │ │Billing API│ │CloudMonitor│ + │iam.vol..│ │billing.vol│ │open.vol.. │ + └─────────┘ └───────────┘ └────────────┘ +``` + +### 1.3 关键发现 + +> **重要**:火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**或**标签(Tag)**维度来实现,由主账号通过 Billing API 查询后聚合展示。 + +--- + +## 2. API 认证与签名机制 + +### 2.1 签名算法 + +火山引擎使用 **HMAC-SHA256** 签名(类似 AWS Signature V4)。 + +**签名流程:** + +``` +1. 构造规范请求 (Canonical Request) + = HTTP方法 + 路径 + 排序后的查询参数 + 规范头部 + 签名头列表 + Body哈希 + +2. 构造待签名字符串 (String to Sign) + = "HMAC-SHA256" + 时间戳 + 凭证范围 + SHA256(规范请求) + +3. 派生签名密钥 + kDate = HMAC-SHA256(SecretKey, 日期) + kRegion = HMAC-SHA256(kDate, "cn-north-1") + kService = HMAC-SHA256(kRegion, 服务名) + kSigning = HMAC-SHA256(kService, "request") + +4. 计算签名 + Signature = Hex(HMAC-SHA256(kSigning, 待签名字符串)) +``` + +### 2.2 完整 Python 签名实现 + +```python +import datetime +import hashlib +import hmac +import requests +from urllib.parse import quote + + +class VolcengineClient: + """火山引擎 API 客户端,处理 HMAC-SHA256 签名认证""" + + def __init__(self, ak: str, sk: str, service: str, host: str, + region: str = "cn-north-1", version: str = "2018-01-01"): + self.ak = ak + self.sk = sk + self.service = service + self.host = host + self.region = region + self.version = version + + def _norm_query(self, params: dict) -> str: + query = "" + for key in sorted(params.keys()): + if isinstance(params[key], list): + for v in params[key]: + query += quote(key, safe="-_.~") + "=" + quote(str(v), safe="-_.~") + "&" + else: + query += quote(key, safe="-_.~") + "=" + quote(str(params[key]), safe="-_.~") + "&" + return query[:-1].replace("+", "%20") if query else "" + + def _hmac_sha256(self, key: bytes, content: str) -> bytes: + return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest() + + def _hash_sha256(self, content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + def call(self, action: str, params: dict = None, body: str = "") -> dict: + """调用火山引擎 API + + Returns: + dict: API 响应。如果 ResponseMetadata 中包含 Error,会抛出 RuntimeError。 + """ + params = params or {} + now = datetime.datetime.now(datetime.timezone.utc) + x_date = now.strftime("%Y%m%dT%H%M%SZ") + short_date = x_date[:8] + + x_content_sha256 = self._hash_sha256(body) + + all_params = {"Action": action, "Version": self.version, **params} + + signed_headers_str = "content-type;host;x-content-sha256;x-date" + canonical_headers = ( + f"content-type:application/x-www-form-urlencoded\n" + f"host:{self.host}\n" + f"x-content-sha256:{x_content_sha256}\n" + f"x-date:{x_date}" + ) + + query_string = self._norm_query(all_params) + canonical_request = "\n".join([ + "GET", "/", query_string, + canonical_headers, "", signed_headers_str, x_content_sha256 + ]) + + credential_scope = f"{short_date}/{self.region}/{self.service}/request" + string_to_sign = "\n".join([ + "HMAC-SHA256", x_date, credential_scope, + self._hash_sha256(canonical_request) + ]) + + k_date = self._hmac_sha256(self.sk.encode("utf-8"), short_date) + k_region = self._hmac_sha256(k_date, self.region) + k_service = self._hmac_sha256(k_region, self.service) + k_signing = self._hmac_sha256(k_service, "request") + signature = self._hmac_sha256(k_signing, string_to_sign).hex() + + headers = { + "Host": self.host, + "X-Date": x_date, + "X-Content-Sha256": x_content_sha256, + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": ( + f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, " + f"SignedHeaders={signed_headers_str}, Signature={signature}" + ) + } + + # 重要:不能使用 requests.get(url, params=...),因为 requests 库会自行 + # URL 编码参数,其编码方式可能与签名时使用的 _norm_query 不一致, + # 导致签名校验失败(401 错误)。必须手动拼接 query string。 + url = f"https://{self.host}/?{query_string}" + r = requests.get(url, headers=headers) + resp = r.json() + + # 检查 API 错误 + error = resp.get("ResponseMetadata", {}).get("Error") + if error: + raise RuntimeError( + f"Volcengine API Error [{action}]: " + f"{error.get('Code', 'Unknown')} - {error.get('Message', '')}" + ) + + return resp +``` + +--- + +## 3. IAM 用户管理 API + +**服务端点:** `https://iam.volcengineapi.com/` +**API 版本:** `2018-01-01` +**服务代码:** `iam` + +### 3.1 用户生命周期 API + +| Action | 说明 | 关键参数 | +|--------|------|----------| +| `CreateUser` | 创建子用户 | `UserName`(必填), `DisplayName`, `Email`, `MobilePhone` | +| `GetUser` | 查询用户详情 | `UserName` | +| `UpdateUser` | 更新用户信息 | `UserName`, `NewUserName`, `NewDisplayName` 等 | +| `ListUsers` | 列出所有用户 | `Limit`, `Offset`(分页) | +| `DeleteUser` | 删除用户 | `UserName` | + +### 3.2 登录管理 API(控制台访问开关) + +| Action | 说明 | 关键参数 | +|--------|------|----------| +| `CreateLoginProfile` | 开通控制台登录 | `UserName`, `Password`, `LoginAllowed`, `PasswordResetRequired` | +| `GetLoginProfile` | 查询登录状态 | `UserName` | +| `UpdateLoginProfile` | **启用/停用用户** | `UserName`, `LoginAllowed`(true/false) | +| `DeleteLoginProfile` | 删除登录能力 | `UserName` | + +> **关键能力**:`UpdateLoginProfile` + `LoginAllowed=false` 可以**停用子账号的控制台访问**,设为 `true` 即可**一键恢复**。 + +### 3.3 GetLoginProfile 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `LoginAllowed` | Boolean | 是否允许登录 | +| `LastLoginDate` | String | 最后登录时间 | +| `LastLoginIp` | String | 最后登录 IP | +| `LoginLocked` | Boolean | 是否被锁定 | +| `SafeAuthFlag` | Boolean | 是否开启 MFA | + +### 3.4 用户组管理 API + +| Action | 说明 | +|--------|------| +| `CreateGroup` | 创建用户组 | +| `AddUserToGroup` | 添加用户到组 | +| `RemoveUserFromGroup` | 移出用户组 | +| `ListGroupsForUser` | 查询用户所在组 | +| `ListUsersForGroup` | 查询组内用户 | + +--- + +## 4. 权限策略管理 + +### 4.1 策略操作 API + +| Action | 说明 | +|--------|------| +| `CreatePolicy` | 创建自定义策略 | +| `GetPolicy` | 查询策略详情 | +| `UpdatePolicy` | 更新策略 | +| `DeletePolicy` | 删除策略 | +| `ListPolicies` | 列出所有策略 | +| `AttachUserPolicy` | 将策略附加到用户 | +| `DetachUserPolicy` | 从用户分离策略 | +| `ListAttachedUserPolicies` | 查询用户已附加的策略 | +| `AttachPolicyInProject` | 在项目范围内附加策略 | +| `DetachPolicyInProject` | 在项目范围内分离策略 | + +### 4.2 系统预置策略(关键) + +| 策略名 | 适用场景 | 说明 | +|--------|----------|------| +| `ArkFullAccess` | Seedance 2.0 | 方舟平台完整管理权限(含模型、端点、微调) | +| `ArkStandardGlobalAccess` | Seedance 2.0 | 标准使用权限(不含模型上线) | +| `ArkReadOnlyAccess` | Seedance 2.0 | 只读权限 | +| `TOSFullAccess` | 对象存储 | TOS 完整管理权限 | +| `TOSReadOnlyAccess` | 对象存储 | TOS 只读权限 | +| `AccessKeySelfManageAccess` | API 密钥 | 用户仅能管理自己的 API 密钥 | + +### 4.3 策略文档格式 + +```json +{ + "Statement": [ + { + "Effect": "Allow | Deny", + "Action": ["服务代码:操作名称"], + "Resource": ["trn:服务代码:区域:账号ID:资源路径"], + "Condition": { + "条件运算符": { + "条件键": ["值"] + } + } + } + ] +} +``` + +**TRN(资源名称)格式:** `trn:${ServiceCode}:${Region}:${AccountId}:${ResourcePath}` + +示例: +- IAM 用户:`trn:iam::2100000000001:user/Bob` +- TOS 存储桶:`trn:tos:::my-bucket` +- TOS 对象:`trn:tos:::my-bucket/path/*` + +### 4.4 推荐的子账号策略配置 + +#### 策略一:允许 Seedance 2.0 + TOS(使用系统策略) + +**方案 A -- 全局授权**(不需要项目隔离时): +``` +AttachUserPolicy: PolicyName=ArkFullAccess, PolicyType=System +AttachUserPolicy: PolicyName=TOSFullAccess, PolicyType=System +AttachUserPolicy: PolicyName=AccessKeySelfManageAccess, PolicyType=System +``` + +**方案 B -- 项目级授权**(推荐,需要隔离不同子账号的资源): +``` +AttachUserPolicy: PolicyName=AccessKeySelfManageAccess, PolicyType=System # 全局 +AttachPolicyInProject: PolicyName=ArkFullAccess, ProjectName=DeptA-Project # 限定在项目内 +AttachPolicyInProject: PolicyName=TOSFullAccess, ProjectName=DeptA-Project # 限定在项目内 +``` + +> **注意**:方案 A 和方案 B 不能混用。如果同时全局附加和项目级附加同一策略,全局策略会使项目限制失效。 + +#### 策略二:禁止查看主账号信息(自定义 Deny 策略) + +> **注意**:此策略故意**排除了** `iam:CreateAccessKey`、`iam:UpdateAccessKey`、`iam:DeleteAccessKey`、`iam:ListAccessKeys`、`iam:GetAccessKeyLastUsed` 等密钥自管理操作,以避免与 `AccessKeySelfManageAccess` 策略冲突。因为 **Deny 优先于 Allow**,如果这里 deny 了 `iam:*`,子账号将无法管理自己的 API 密钥。 + +```json +{ + "Statement": [ + { + "Effect": "Deny", + "Action": [ + "iam:ListUsers", + "iam:GetUser", + "iam:ListGroups", + "iam:GetGroup", + "iam:ListRoles", + "iam:GetRole", + "iam:ListPolicies", + "iam:GetPolicy", + "iam:ListAttachedUserPolicies", + "iam:ListAttachedRolePolicies", + "iam:ListEntitiesForPolicy", + "iam:GetLoginProfile", + "iam:GetSecurityConfig", + "iam:CreateUser", + "iam:UpdateUser", + "iam:DeleteUser", + "iam:CreateGroup", + "iam:UpdateGroup", + "iam:DeleteGroup", + "iam:CreateRole", + "iam:UpdateRole", + "iam:DeleteRole", + "iam:CreatePolicy", + "iam:UpdatePolicy", + "iam:DeletePolicy", + "iam:AttachUserPolicy", + "iam:DetachUserPolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:CreateLoginProfile", + "iam:UpdateLoginProfile", + "iam:DeleteLoginProfile", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup", + "iam:ListUsersForGroup", + "iam:ListGroupsForUser", + "iam:SetSecurityConfig", + "iam:CreateServiceLinkedRole", + "iam:DeleteServiceLinkedRole", + "iam:AttachUserGroupPolicy", + "iam:DetachUserGroupPolicy", + "iam:ListAttachedUserGroupPolicies", + "iam:AssumeRole" + ], + "Resource": ["*"] + }, + { + "Effect": "Deny", + "Action": [ + "billing:*", + "bss:*" + ], + "Resource": ["*"] + }, + { + "Effect": "Deny", + "Action": [ + "organization:*" + ], + "Resource": ["*"] + } + ] +} +``` + +> **原理**:IAM 子用户**默认没有任何权限**。即使不加 Deny 策略,子用户也看不到主账号信息。但显式 Deny 可以防止其他策略意外授权。Deny 优先级始终高于 Allow。 +> +> **关键设计**:Deny 策略中明确列出了要禁止的 IAM 操作,而**没有使用 `iam:*` 通配符**。这样不会阻断 `AccessKeySelfManageAccess` 授予的密钥自管理能力(`iam:CreateAccessKey`、`iam:UpdateAccessKey`、`iam:DeleteAccessKey`、`iam:ListAccessKeys`)。 + +#### 策略三:允许用户管理自己的 API 密钥 + +已有系统预置策略 `AccessKeySelfManageAccess`,直接附加即可。 + +#### 策略四:TOS 限定到指定存储桶 + +```json +{ + "Statement": [ + { + "Effect": "Allow", + "Action": ["tos:*"], + "Resource": [ + "trn:tos:::department-bucket", + "trn:tos:::department-bucket/*" + ] + }, + { + "Effect": "Allow", + "Action": ["tos:ListBuckets"], + "Resource": ["*"] + } + ] +} +``` + +--- + +## 5. API 密钥管理 + +| Action | 说明 | 关键参数 | +|--------|------|----------| +| `CreateAccessKey` | 创建 API 密钥对 | `UserName`(可选,不填=为自己创建) | +| `ListAccessKeys` | 列出用户的密钥 | `UserName` | +| `UpdateAccessKey` | **启用/停用密钥** | `AccessKeyId`, `Status`(active/inactive) | +| `DeleteAccessKey` | 删除密钥 | `AccessKeyId` | +| `GetAccessKeyLastUsed` | 查询密钥最后使用 | `AccessKeyId` | + +### 重要限制 + +- **每个用户最多 2 个 API 密钥** +- **SecretAccessKey 仅在创建时返回一次**,之后无法再获取 +- 停用密钥后,使用该密钥的所有 API 调用将立即失败 + +### 停用子账号的 API 访问 + +```python +# 停用密钥 = 立即切断子账号的所有 API 调用能力 +iam_client.call("UpdateAccessKey", { + "AccessKeyId": "AKLT****", + "Status": "inactive", + "UserName": "sub_user_1" +}) +``` + +### 恢复子账号的 API 访问 + +```python +# 恢复密钥 = 一键恢复 +iam_client.call("UpdateAccessKey", { + "AccessKeyId": "AKLT****", + "Status": "active", + "UserName": "sub_user_1" +}) +``` + +--- + +## 6. 计费与消费查询 API + +**服务端点:** `https://billing.volcengineapi.com` +**API 版本:** `2022-01-01` +**服务代码:** `billing` +**QPS 限制:** 5 QPS + +### 6.1 账单查询 API + +| Action | 说明 | 粒度 | +|--------|------|------| +| `ListBillOverviewByCategory` | 按类别汇总 | 月 | +| `ListBillOverviewByProd` | 按产品汇总 | 月 | +| `ListBill` | 账单流水 | 月 | +| `ListBillDetail` | **明细账单**(最细粒度) | 日/月 | +| `ListSplitBillDetail` | 分账账单(按资源拆分) | 月 | +| `ListAmortizedCostBillDaily` | 每日摊销成本 | 日 | + +### 6.2 ListBillDetail 关键参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `BillPeriod` | String | 是 | 格式 YYYY-MM,近 24 个月 | +| `Limit` | Integer | 是 | 每页数量 1-300 | +| `Offset` | Integer | 否 | 分页偏移 | +| `OwnerID` | Array[Long] | 否 | 按资源拥有者筛选 | +| `Product` | Array[String] | 否 | 按产品筛选 | +| `GroupTerm` | Integer | 否 | 0=明细, 1=实例, 2=产品, 3=账号 | +| `GroupPeriod` | Integer | 否 | 0=账期, 1=日, 2=详情 | +| `ExpenseDate` | String | 否 | 特定日期(需 GroupPeriod=1) | +| `InstanceNo` | String | 否 | 实例 ID 筛选 | + +### 6.3 按子账号追踪消费的方法 + +> **核心问题**:IAM 子账号没有独立的计费维度。不能直接按 IAM UserName 查询消费。 + +**可行方案:** + +| 方案 | 实现方式 | 精确度 | +|------|----------|--------| +| **按项目追踪** | 为每个子账号/部门创建独立项目,资源都放在项目中 | 高 | +| **按标签追踪** | 资源打上子账号标签(如 `owner=sub_user_1`) | 高 | +| **按 Ark 端点追踪** | Seedance/方舟按 Endpoint 分账(ListSplitBillDetail) | 中 | +| **按 TOS 存储桶追踪** | TOS 按 Bucket 分账 | 高 | + +**推荐方案:项目 + 标签 双维度** + +``` +1. 创建项目 "DeptA-Project" +2. 子账号的权限限定在该项目范围内 (AttachPolicyInProject) +3. 资源打标签 tag: {"department": "DeptA", "owner": "sub_user_1"} +4. 通过 ListBillDetail + ListSplitBillDetail 按项目/标签筛选消费 +``` + +### 6.4 账户余额查询 + +```python +billing_client = VolcengineClient(AK, SK, "billing", "billing.volcengineapi.com", + version="2022-01-01") + +# 查询账户余额 +balance = billing_client.call("QueryBalanceAcct") +# 返回:可用余额、冻结金额等 +``` + +### 6.5 数据时效性 + +| 数据类型 | 可用时间 | +|----------|----------| +| 上月完整账单 | 每月 2 日 12:00 | +| 日粒度账单 | T+1 ~ T+2 天 | +| 分账账单 | ~2 天延迟 | +| 摊销成本 | ~2 天延迟 | +| **实时账单** | **不支持** | + +--- + +## 7. 预算与告警机制 + +### 7.1 预算管理 API(Billing 模块) + +| Action | 说明 | +|--------|------| +| `CreateBudget` | 创建预算 | +| `UpdateBudget` | 更新预算 | +| `ListBudget` | 查询预算列表 | +| `QueryBudgetDetail` | 查询预算详情 | +| `DeleteBudget` | 删除预算 | +| `ListBudgetAmountByBudgetID` | 查询预算金额 | +| `ListRecipientInformation` | 查询告警接收人 | + +**预算可按以下维度筛选:** +- 区域、产品、标签、项目、账号 OwnerID、付款人 PayerID、可用区、计费模式 + +### 7.2 CloudMonitor Webhook 告警 + +**端点:** `https://open.volcengineapi.com?Action={Action}&Version=2018-01-01` + +| Action | 说明 | +|--------|------| +| `CreateRule` | 创建告警规则(支持 Webhook) | +| `CreateWebhook` | 配置 Webhook 回调地址 | +| `CreateContacts` | 添加告警联系人 | +| `CreateContactGroup` | 创建通知组 | + +**支持的通知渠道:** +- 站内信(默认开启) +- Email +- SMS +- 飞书(Feishu) +- 钉钉(DingTalk) +- 企业微信 +- 自定义 Webhook(HTTP POST 回调) + +### 7.3 Ark 推理限额(Seedance 专属) + +火山方舟(Ark)平台有**推理限额**功能: +- 可设置每个模型的最大 Token 消耗量 +- **达到限额后服务自动暂停** +- 最小调整间隔 2 小时 +- 仅支持在线推理(不含批量) +- 目前仅支持通过控制台设置,**暂无公开 API** + +### 7.4 自建告警方案(推荐) + +由于火山原生的预算告警仅支持站内信/邮件/短信通知,不支持自动停用。需要自建: + +``` +┌──────────────────────────────────────────────┐ +│ 定时任务 (每小时/每天) │ +│ │ +│ 1. 调用 ListBillDetail 查询各子账号消费 │ +│ 2. 与预设阈值对比 │ +│ 3. 达到告警阈值 → 发送通知 │ +│ 4. 达到停用阈值 → 调用 IAM API 停用用户 │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 8. 子账号自动停用/恢复方案 + +### 8.1 完全停用子账号(保留账号,可恢复) + +需要**同时**执行两个操作才能完全停用: + +```python +def get_user_access_keys(iam_client, username: str) -> list: + """获取用户的所有 AccessKey ID""" + result = iam_client.call("ListAccessKeys", {"UserName": username}) + keys = result.get("Result", {}).get("AccessKeyMetadata", []) + return [k["AccessKeyId"] for k in keys] + + +def disable_sub_user(iam_client, username: str, access_key_ids: list = None): + """完全停用子账号(保留账号,可一键恢复)""" + + # 0. 如果未传入 access_key_ids,自动查询 + if access_key_ids is None: + access_key_ids = get_user_access_keys(iam_client, username) + + # 1. 停用控制台登录 + iam_client.call("UpdateLoginProfile", { + "UserName": username, + "LoginAllowed": "false" + }) + + # 2. 停用所有 API 密钥 + for ak_id in access_key_ids: + iam_client.call("UpdateAccessKey", { + "AccessKeyId": ak_id, + "Status": "inactive", + "UserName": username + }) + + print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个 API 密钥)") +``` + +### 8.2 一键恢复子账号 + +```python +def enable_sub_user(iam_client, username: str, access_key_ids: list = None): + """一键恢复子账号""" + + # 0. 如果未传入 access_key_ids,自动查询 + if access_key_ids is None: + access_key_ids = get_user_access_keys(iam_client, username) + + # 1. 恢复控制台登录 + iam_client.call("UpdateLoginProfile", { + "UserName": username, + "LoginAllowed": "true" + }) + + # 2. 恢复所有 API 密钥 + for ak_id in access_key_ids: + iam_client.call("UpdateAccessKey", { + "AccessKeyId": ak_id, + "Status": "active", + "UserName": username + }) + + print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个 API 密钥)") +``` + +### 8.3 停用 vs 删除的区别 + +| 操作 | 效果 | 可恢复 | +|------|------|--------| +| `UpdateLoginProfile(LoginAllowed=false)` | 停用控制台登录 | 一键恢复 | +| `UpdateAccessKey(Status=inactive)` | 停用 API 访问 | 一键恢复 | +| `DetachUserPolicy` | 移除权限但保留用户 | 重新附加即可 | +| `DeleteUser` | **永久删除用户** | **不可恢复** | + +--- + +## 9. 项目管理与资源隔离 + +### 9.1 项目管理 API + +**端点:** `https://open.volcengineapi.com` + +| Action | 说明 | +|--------|------| +| `CreateProject` | 创建项目 | +| `ListProjects` | 列出项目 | +| `GetProject` | 获取项目详情 | +| `UpdateProject` | 更新项目 | +| `DeleteProject` | 删除项目 | +| `ListProjectResources` | 列出项目中的资源 | +| `MoveProjectResource` | 在项目间移动资源 | +| `ListProjectIdentities` | 列出项目中的用户/角色 | + +### 9.2 项目级权限授权 + +```python +# 在项目范围内授权(子账号只能访问该项目下的资源) +iam_client.call("AttachPolicyInProject", { + "UserName": "sub_user_1", + "PolicyName": "ArkFullAccess", + "PolicyType": "System", + "ProjectName": "DeptA-Project" +}) +``` + +**效果:** 子账号仅能操作 `DeptA-Project` 项目下的 Ark/Seedance 资源,无法看到其他项目的内容。 + +### 9.3 标签管理 API + +| Action | 说明 | +|--------|------| +| `TagResources` | 给资源打标签 | +| `UntagResources` | 移除标签 | +| `ListTagsForResources` | 查询资源标签 | + +**标签用于:** +- 资源分组与管理 +- 按标签筛选账单(在 ListBillDetail 响应中的 `Tag` 字段) +- IAM 条件策略(基于标签的访问控制) + +--- + +## 10. SDK 与工具链 + +### 10.1 推荐 SDK + +| 语言 | 包名 | 安装 | 覆盖 IAM/Billing | +|------|------|------|-------------------| +| **Python**(推荐) | `volcengine-python-sdk` | `pip install volcengine-python-sdk` | 是 | +| Go | `volcengine-go-sdk` | `go get github.com/volcengine/volcengine-go-sdk` | 是 | +| Node.js | `@volcengine/openapi` | `npm install @volcengine/openapi` | 是 | +| Java | `volcengine-java-sdk` | Maven | 是 | + +### 10.2 Python SDK 使用示例 + +```python +import volcenginesdkcore +import volcenginesdkiam + +# 配置 +configuration = volcenginesdkcore.Configuration() +configuration.ak = "YOUR_AK" +configuration.sk = "YOUR_SK" +configuration.region = "cn-beijing" +volcenginesdkcore.Configuration.set_default(configuration) + +# IAM 操作 +iam_api = volcenginesdkiam.IAMApi( + volcenginesdkcore.ApiClient(configuration) +) + +# 列出用户 +users = iam_api.list_users(volcenginesdkiam.ListUsersRequest( + limit=100, + offset=0 +)) +``` + +### 10.3 CLI 工具 + +```bash +# 安装 Volcengine CLI +# 从 https://github.com/volcengine/volcengine-cli/releases 下载 + +# 配置 +ve configure set --profile default --region cn-beijing \ + --access-key YOUR_AK --secret-key YOUR_SK + +# 使用 +ve iam ListUsers +ve iam CreateUser --UserName "sub_user_1" --DisplayName "Sub User 1" +ve billing ListBillDetail --BillPeriod "2026-03" --Limit 100 +``` + +--- + +## 11. 可执行实施方案 + +### 第一阶段:基础搭建 + +#### Step 1:创建子账号 + +```python +# 创建 IAM 客户端 +iam = VolcengineClient(AK, SK, "iam", "iam.volcengineapi.com") + +# 创建子用户 +iam.call("CreateUser", { + "UserName": "dept_a_user", + "DisplayName": "部门A用户", + "Email": "dept_a@company.com", + "MobilePhone": "+8618000000000" +}) + +# 开通控制台登录 +iam.call("CreateLoginProfile", { + "UserName": "dept_a_user", + "Password": "Initial@Pass123", + "LoginAllowed": "true", + "PasswordResetRequired": "true" +}) + +# 创建 API 密钥(记录返回的 SecretAccessKey!) +result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"}) +# result["Result"]["AccessKey"]["SecretAccessKey"] -- 仅此一次! +``` + +#### Step 2:配置权限 + +> **重要**:如果要通过项目隔离资源(Step 4),**不要**在此处全局附加 `ArkFullAccess` / `TOSFullAccess`, +> 否则全局策略会覆盖项目级限制,子账号将能访问所有项目的资源。 +> 应当仅在项目范围内授权(见 Step 4),或者如果不需要项目隔离则可以全局附加。 + +```python +# 方案 A:不需要项目隔离时,全局授权 +# iam.call("AttachUserPolicy", { +# "UserName": "dept_a_user", +# "PolicyName": "ArkFullAccess", +# "PolicyType": "System" +# }) +# iam.call("AttachUserPolicy", { +# "UserName": "dept_a_user", +# "PolicyName": "TOSFullAccess", +# "PolicyType": "System" +# }) + +# 方案 B(推荐):需要项目隔离时,此处只附加密钥自管理策略 +# Ark 和 TOS 的权限在 Step 4 中通过 AttachPolicyInProject 在项目范围内授权 + +# 允许自行管理 API 密钥(此策略需全局附加,不受项目限制) +iam.call("AttachUserPolicy", { + "UserName": "dept_a_user", + "PolicyName": "AccessKeySelfManageAccess", + "PolicyType": "System" +}) +``` + +#### Step 3:创建并附加 Deny 策略 + +```python +import json + +deny_policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": [ + "iam:ListUsers", "iam:GetUser", + "iam:ListGroups", "iam:GetGroup", + "iam:ListRoles", "iam:GetRole", + "iam:ListPolicies", "iam:GetPolicy", + "iam:ListAttachedUserPolicies", "iam:ListAttachedRolePolicies", + "iam:ListEntitiesForPolicy", "iam:GetLoginProfile", + "iam:GetSecurityConfig", + "iam:CreateUser", "iam:UpdateUser", "iam:DeleteUser", + "iam:CreateGroup", "iam:UpdateGroup", "iam:DeleteGroup", + "iam:CreateRole", "iam:UpdateRole", "iam:DeleteRole", + "iam:CreatePolicy", "iam:UpdatePolicy", "iam:DeletePolicy", + "iam:AttachUserPolicy", "iam:DetachUserPolicy", + "iam:AttachRolePolicy", "iam:DetachRolePolicy", + "iam:CreateLoginProfile", "iam:UpdateLoginProfile", + "iam:DeleteLoginProfile", + "iam:AddUserToGroup", "iam:RemoveUserFromGroup", + "iam:ListUsersForGroup", "iam:ListGroupsForUser", + "iam:SetSecurityConfig", + "iam:CreateServiceLinkedRole", "iam:DeleteServiceLinkedRole", + "iam:AttachUserGroupPolicy", "iam:DetachUserGroupPolicy", + "iam:ListAttachedUserGroupPolicies", + "iam:AssumeRole" + ], + "Resource": ["*"] + }, + { + "Effect": "Deny", + "Action": ["billing:*", "bss:*"], + "Resource": ["*"] + }, + { + "Effect": "Deny", + "Action": ["organization:*"], + "Resource": ["*"] + } + ] +} + +# 注意:PolicyDocument 不要额外 URL 编码,VolcengineClient._norm_query 会自动编码 +iam.call("CreatePolicy", { + "PolicyName": "DenyAdminAndBilling", + "Description": "禁止访问 IAM 管理和计费信息", + "PolicyDocument": json.dumps(deny_policy) +}) + +iam.call("AttachUserPolicy", { + "UserName": "dept_a_user", + "PolicyName": "DenyAdminAndBilling", + "PolicyType": "Custom" +}) +``` + +### 第二阶段:消费监控 + +#### Step 4:创建项目并分配 + +```python +# 使用项目管理 API(通过 open.volcengineapi.com) +# 注意:项目管理的 service 签名名称需要根据实际情况确认, +# 可能是 "resource_manager" 或其他名称,建议先用 API Explorer 测试 +project_client = VolcengineClient(AK, SK, "resource_manager", "open.volcengineapi.com") + +# 创建项目 +project_client.call("CreateProject", { + "ProjectName": "DeptA-Project", + "Description": "部门A专属项目" +}) + +# 在项目范围内授权(子账号只能操作此项目下的 Ark 和 TOS 资源) +iam.call("AttachPolicyInProject", { + "UserName": "dept_a_user", + "PolicyName": "ArkFullAccess", + "PolicyType": "System", + "ProjectName": "DeptA-Project" +}) + +iam.call("AttachPolicyInProject", { + "UserName": "dept_a_user", + "PolicyName": "TOSFullAccess", + "PolicyType": "System", + "ProjectName": "DeptA-Project" +}) +``` + +#### Step 5:消费查询脚本 + +```python +billing = VolcengineClient(AK, SK, "billing", "billing.volcengineapi.com", + version="2022-01-01") + +def get_user_spending(bill_period: str, project_name: str = None) -> float: + """查询指定项目/用户的消费金额(带分页处理)""" + total = 0.0 + offset = 0 + page_size = 300 + + while True: + params = { + "BillPeriod": bill_period, + "Limit": str(page_size), + "Offset": str(offset), + "GroupTerm": "0", # 明细级别(非聚合),确保 Project 字段可用 + "GroupPeriod": "0", # 按账期 + "NeedRecordNum": "1", + } + + result = billing.call("ListBillDetail", params) + items = result.get("Result", {}).get("List", []) + record_num = int(result.get("Result", {}).get("Total", 0)) + + for item in items: + # 按项目筛选 + if project_name and item.get("Project") != project_name: + continue + total += float(item.get("PayableAmount", "0")) + + # 分页:如果还有更多数据,继续查询 + offset += page_size + if offset >= record_num or not items: + break + + return total +``` + +### 第三阶段:告警与自动停用 + +#### Step 6:消费监控与告警服务 + +```python +import time +import logging +import requests as http_requests + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("volcengine_monitor") + +# 配置 +ALERT_THRESHOLD = 1000.0 # 告警阈值(元) +DISABLE_THRESHOLD = 5000.0 # 停用阈值(元) +CHECK_INTERVAL = 3600 # 检查间隔(秒),每小时 + +FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK_ID" + +MANAGED_USERS = [ + { + "username": "dept_a_user", + "project": "DeptA-Project", + "alert_threshold": ALERT_THRESHOLD, + "disable_threshold": DISABLE_THRESHOLD, + } +] + +# 记录已停用的用户,避免重复停用和重复告警 +_disabled_users: set = set() +# 记录已发送告警的用户,避免每轮都重复告警 +_alerted_users: set = set() + + +def send_feishu_alert(title: str, content: str): + """发送飞书告警""" + payload = { + "msg_type": "interactive", + "card": { + "header": {"title": {"tag": "plain_text", "content": title}}, + "elements": [ + {"tag": "div", "text": {"tag": "plain_text", "content": content}} + ] + } + } + try: + resp = http_requests.post(FEISHU_WEBHOOK, json=payload, timeout=10) + resp.raise_for_status() + except Exception as e: + logger.error(f"飞书告警发送失败: {e}") + + +def check_and_alert(): + """检查消费并触发告警/停用""" + from datetime import datetime + bill_period = datetime.now().strftime("%Y-%m") + + for user in MANAGED_USERS: + username = user["username"] + + # 已停用的用户跳过(避免重复停用和告警) + if username in _disabled_users: + logger.info(f"用户 {username} 已处于停用状态,跳过检查") + continue + + try: + spending = get_user_spending(bill_period, user["project"]) + except RuntimeError as e: + logger.error(f"查询用户 {username} 消费失败: {e}") + continue + + logger.info(f"用户 {username} 本月消费: ¥{spending:.2f}") + + if spending >= user["disable_threshold"]: + # 自动停用(自动查询该用户的 AccessKey) + try: + disable_sub_user(iam, username) + _disabled_users.add(username) + send_feishu_alert( + "【紧急】子账号已自动停用", + f"用户 {username} 本月消费 ¥{spending:.2f}," + f"已达到停用阈值 ¥{user['disable_threshold']:.2f},已自动停用。\n" + f"如需恢复,请在管控工具中操作。" + ) + except RuntimeError as e: + logger.error(f"停用用户 {username} 失败: {e}") + send_feishu_alert("【错误】自动停用失败", + f"用户 {username} 消费 ¥{spending:.2f},停用失败: {e}") + + elif spending >= user["alert_threshold"] and username not in _alerted_users: + # 发送告警(每账期只告警一次) + _alerted_users.add(username) + send_feishu_alert( + "【警告】子账号消费告警", + f"用户 {username} 本月消费 ¥{spending:.2f}," + f"已达到告警阈值 ¥{user['alert_threshold']:.2f}。\n" + f"停用阈值:¥{user['disable_threshold']:.2f}" + ) + + +# 主循环(生产环境建议用 cron 或 APScheduler) +def main(): + logger.info("消费监控服务启动") + while True: + try: + check_and_alert() + except Exception as e: + logger.error(f"监控服务异常: {e}") + send_feishu_alert("【错误】监控服务异常", str(e)) + time.sleep(CHECK_INTERVAL) +``` + +> **注意**:每月初应重置 `_disabled_users` 和 `_alerted_users` 集合(或使用持久化存储), +> 否则跨月后状态不正确。生产环境建议将状态存入数据库或 Redis。 + +#### Step 7:管理后台 API 接口设计 + +``` +# 用户管理 +GET /api/iam/users # 列出所有子账号 +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 # 停用子账号 +POST /api/iam/users/{username}/enable # 恢复子账号 + +# 权限管理 +GET /api/iam/users/{username}/policies # 查看子账号权限 +POST /api/iam/users/{username}/policies # 附加权限 +DELETE /api/iam/users/{username}/policies # 移除权限 + +# 密钥管理 +GET /api/iam/users/{username}/access-keys # 列出密钥 +POST /api/iam/users/{username}/access-keys # 创建密钥 +PUT /api/iam/users/{username}/access-keys/{id} # 启停密钥 + +# 消费查询 +GET /api/billing/users/{username}/spending # 查询子账号消费 +GET /api/billing/overview # 消费总览 + +# 告警配置 +GET /api/alerts/config # 查看告警配置 +PUT /api/alerts/config # 更新阈值配置 +GET /api/alerts/history # 告警历史 +``` + +--- + +## 12. 限制与注意事项 + +### 12.1 关键限制 + +| 限制项 | 说明 | +|--------|------| +| IAM 子账号无独立计费 | 所有费用归主账号,需通过项目/标签追踪 | +| Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 | +| 每用户最多 2 个 API 密钥 | 无法创建更多 | +| SecretKey 仅返回一次 | 创建后立即保存 | +| Billing API QPS 限制 5 | 批量查询需注意限流 | +| Ark 推理限额无公开 API | 目前仅支持控制台操作 | +| 预算告警仅通知不自动执行 | 需自建自动停用逻辑 | + +### 12.2 安全建议 + +1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务 +2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥 +3. **遵循最小权限原则**,只授予必要的权限 +4. **显式 Deny 策略优先**,防止权限漏洞 +5. **监控日志**,使用 CloudTrail 审计 API 调用 + +### 12.3 消费监控的精确度问题 + +由于账单数据有 1-2 天延迟,消费监控存在滞后。应对策略: +- 设置更保守的阈值(如实际想控制 5000 元,告警阈值设 3000,停用阈值设 4000) +- 结合 Ark 推理限额功能(自动暂停,无延迟) +- 高频轮询(如每小时)以尽早发现异常 + +--- + +## 13. 参考文档 + +### 官方文档 + +| 文档 | URL | +|------|-----| +| IAM API 概览 | https://www.volcengine.com/docs/6257/65842 | +| IAM 基本概念 | https://www.volcengine.com/docs/6257/64963 | +| 创建用户并授权 | https://www.volcengine.com/docs/6257/94013 | +| CreateAccessKey | https://www.volcengine.com/docs/6257/65000 | +| AttachUserPolicy | https://www.volcengine.com/docs/6257/65029 | +| LoginProfile 管理 | https://www.volcengine.com/docs/6257/65013 | +| 策略概述 | https://www.volcengine.com/docs/6257/65058 | +| 策略基本结构 | https://www.volcengine.com/docs/6257/65059 | +| 系统预置策略 | https://www.volcengine.com/docs/6257/1253730 | +| 自定义策略 | https://www.volcengine.com/docs/6257/1158323 | +| Billing API 概览 | https://www.volcengine.com/docs/6269/1165275 | +| ListBillDetail | https://www.volcengine.com/docs/6269/1127842 | +| 预算管理 | https://www.volcengine.com/docs/6269/1165274 | +| 分账账单 | https://www.volcengine.com/docs/6269/177196 | +| 计费权限管理 | https://www.volcengine.com/docs/6269/1186807 | +| 项目管理 | https://www.volcengine.com/docs/6649/166155 | +| TOS IAM 策略 | https://www.volcengine.com/docs/6349/102133 | +| Ark IAM 教程 | https://www.volcengine.com/docs/82379/1263493 | +| Ark 推理限额 | https://www.volcengine.com/docs/82379/1159200 | +| CloudMonitor API | https://www.volcengine.com/docs/6408/78940 | +| API 签名方法 | https://www.volcengine.com/docs/6369/67269 | + +### GitHub 资源 + +| 资源 | URL | +|------|-----| +| Volcengine GitHub | https://github.com/volcengine | +| Python SDK | https://github.com/volcengine/volcengine-python-sdk | +| Go SDK | https://github.com/volcengine/volcengine-go-sdk | +| Node.js SDK | https://github.com/volcengine/volc-sdk-nodejs | +| OpenAPI Demos | https://github.com/volcengine/volc-openapi-demos | +| CLI 工具 | https://github.com/volcengine/volcengine-cli | +| TOS Python SDK | https://github.com/volcengine/ve-tos-python-sdk | + +### 其他资源 + +| 资源 | URL | +|------|-----| +| API Explorer | https://api.volcengine.com/api-docs | +| SDK Center | https://api.volcengine.com/api-sdk | +| PyPI (新 SDK) | https://pypi.org/project/volcengine-python-sdk/ | +| npm | https://www.npmjs.com/package/@volcengine/openapi | + +--- + +> **下一步行动**:基于此报告,可以开始开发管控工具的后端服务。建议使用 Python + `volcengine-python-sdk`,先实现核心的 IAM 管理和消费监控功能,再逐步集成到 AirDrama 管理后台。