From 555c86ce7688961412d6d15f046b90e698c2b615 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 19 Mar 2026 13:03:30 +0800 Subject: [PATCH] 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) --- .env.example | 34 + .gitignore | 35 + backend/apps/__init__.py | 0 backend/apps/accounts/__init__.py | 0 backend/apps/accounts/admin.py | 8 + backend/apps/accounts/apps.py | 6 + .../apps/accounts/migrations/0001_initial.py | 44 + backend/apps/accounts/migrations/__init__.py | 0 backend/apps/accounts/models.py | 14 + backend/apps/accounts/serializers.py | 12 + backend/apps/accounts/urls.py | 8 + backend/apps/accounts/views.py | 60 + backend/apps/monitor/__init__.py | 0 backend/apps/monitor/admin.py | 30 + backend/apps/monitor/apps.py | 14 + .../apps/monitor/migrations/0001_initial.py | 117 ++ backend/apps/monitor/migrations/__init__.py | 0 backend/apps/monitor/models.py | 148 ++ backend/apps/monitor/permissions.py | 11 + backend/apps/monitor/serializers.py | 107 + backend/apps/monitor/urls.py | 33 + backend/apps/monitor/views.py | 419 ++++ backend/config/__init__.py | 0 backend/config/settings.py | 147 ++ backend/config/urls.py | 19 + backend/config/wsgi.py | 12 + backend/manage.py | 28 + backend/requirements.txt | 9 + backend/utils/__init__.py | 0 backend/utils/billing_service.py | 59 + backend/utils/crypto.py | 56 + backend/utils/feishu.py | 42 + backend/utils/iam_service.py | 120 ++ backend/utils/scheduler.py | 144 ++ backend/utils/volcengine_client.py | 115 ++ frontend/.gitignore | 24 + frontend/README.md | 5 + frontend/index.html | 13 + frontend/package-lock.json | 1721 +++++++++++++++++ frontend/package.json | 23 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 3 + frontend/src/api/index.js | 30 + frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/vite.svg | 1 + frontend/src/layouts/MainLayout.vue | 65 + frontend/src/main.js | 21 + frontend/src/router/index.js | 36 + frontend/src/stores/auth.js | 30 + frontend/src/style.css | 16 + frontend/src/views/LoginView.vue | 84 + frontend/src/views/alerts/AlertList.vue | 72 + frontend/src/views/billing/BillingView.vue | 115 ++ .../src/views/dashboard/DashboardView.vue | 80 + frontend/src/views/iam/IAMUserList.vue | 199 ++ frontend/src/views/settings/SettingsView.vue | 171 ++ frontend/vite.config.js | 15 + 火山引擎IAM子账号管控工具_深度研究报告.md | 1218 ++++++++++++ 59 files changed, 5818 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/apps/__init__.py create mode 100644 backend/apps/accounts/__init__.py create mode 100644 backend/apps/accounts/admin.py create mode 100644 backend/apps/accounts/apps.py create mode 100644 backend/apps/accounts/migrations/0001_initial.py create mode 100644 backend/apps/accounts/migrations/__init__.py create mode 100644 backend/apps/accounts/models.py create mode 100644 backend/apps/accounts/serializers.py create mode 100644 backend/apps/accounts/urls.py create mode 100644 backend/apps/accounts/views.py create mode 100644 backend/apps/monitor/__init__.py create mode 100644 backend/apps/monitor/admin.py create mode 100644 backend/apps/monitor/apps.py create mode 100644 backend/apps/monitor/migrations/0001_initial.py create mode 100644 backend/apps/monitor/migrations/__init__.py create mode 100644 backend/apps/monitor/models.py create mode 100644 backend/apps/monitor/permissions.py create mode 100644 backend/apps/monitor/serializers.py create mode 100644 backend/apps/monitor/urls.py create mode 100644 backend/apps/monitor/views.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/settings.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/billing_service.py create mode 100644 backend/utils/crypto.py create mode 100644 backend/utils/feishu.py create mode 100644 backend/utils/iam_service.py create mode 100644 backend/utils/scheduler.py create mode 100644 backend/utils/volcengine_client.py create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/alerts/AlertList.vue create mode 100644 frontend/src/views/billing/BillingView.vue create mode 100644 frontend/src/views/dashboard/DashboardView.vue create mode 100644 frontend/src/views/iam/IAMUserList.vue create mode 100644 frontend/src/views/settings/SettingsView.vue create mode 100644 frontend/vite.config.js create mode 100644 火山引擎IAM子账号管控工具_深度研究报告.md 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 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 zcma%i^5TDbT`tlgo2c`(n!ND-Q6MGAYIbZ-QCh5-QC^YozK_ne*b_MKK#O- zIWy zd$aJVZ?rl%;eiC7d#Sl-cWLv9rA0(UOX(@I3k&yyL+3GaQ4xpb1EGC|i|{byaTI># zBO=0pyZu5XO!hzGNPch4cx%6XJAJpDa<+98BOcYNo1=XER1sv!UW z^>ZDMp%FSmVnt)n^EIR+Nth`vRO^_=UF3EWv75ym{S;#2F8MPot@-y$>ioj!)a1bE zijXPQY;U`qNwl9|wl{W>{FhMSb<>m4{;8Udp4psl)NwFRo(W-T)Y6-qDf=L#U?g<@ zV+T|3+RuE~!E&nodKrkfPcOpJ)&1|p`Tbtd12@MSE8DjWkD|9M>GZsHLf>TTbLx)B z#5K5l%gS7s(yWk?Lj{Nvm`Z-s8xb-Xr`5-xRr%w8v>!oSz{dN*MmxbscQl#Z40qSd z!PQXs-utLEF&$@S#__Lo*pOhG{l(%jyCh-0ME8owiT>U~r&q@MaDRePL(aZAAff9= zBd@*7RZxmiqK^nZH7`bTjIEQw#Y=V6(h{$>7ZIf=7S0;$8~4NXLd4T;Ai~C8&3k-; zYEtJWq6x$#5rrCJ%zspgO z((R)&>BIkkr^qQSEZljO*B+ZDvTeBKJ9N%8Ej=U+62GI)dc|ZMEM66~W12v&QFAIS zoDs`J`wjsl?WdE(NTnjCO!^yB>{yU-2UPT`&FOyVQVmxy#un2Po>GiPPfzd0M^d_i z+Kr}dPhIfsDLd~jOiJ(sHTN;2u)@MaX&0AdXR;BAwr_;1sR;)MM+&{XTzNnKWH@0a zoy9ApaUt=>jjHICu3W42)5;nzHS!M3?aOvZfv-sIc%wc9#l0uHFc}aS4JSrIDOQ?4ri_bS?pjH{U{6qr+6m z--%u=5oc&PxE==-I$~$5gw}yiu_y_o?|ag2+rAgSg%G)}EU}r%*A|v|pjbE`lxJpU zy0{?;(US(i-TiKq6s_(KTYy|YVi&!plMT)EJ4wMU{C7Y;!Xow1nJ+X@ks@r0v25R; z*o$8AP*G*f3$UlYR~18PxKyPj9vU#v)4#GgEx4*?KOhlh>0%3M$-LN7&b*0fXgm$k zH78>bObkx^3_K+RY;G+Usy6L}p9iT!hlnJCmR=;=JL1TdtB#vL!RTJ1TABQx8Ux0w zl^{Jkf(hU>-jr59iK_v-PkV!WwG!LvW<@{3{IbbSiWBrX@S8^`8JFRrc+(AqsUIvm zCTstACtCZ~qy-5^Gr@_z#X!N1*1vH=7@8oL4AEOxWl^YW&LW|1$1J?gG061vk1epe zRI_*s(lrX?-2#tCt_`)p?{zZC+)onl60CU~%4!vPA}h0+fB9ucNkTQ3u29((9Wq=> z^JUm|{_2-=?dMKu&9)#x{lgPOCM`U1^tXDbmZ%I$0fw7|Y-@3Tyj1LGfk$lvzYC85 z=R()QEER%Dz=mTMZ=7E?K74&?)4b~-uj34rKwb~7vU(48%+1xYc^VYn| zncI4NL8xEnmi>eM9EK&~si%*s|BX@zKIUU?cAWA5pdc`xEZIF1Ce=Wcg3#AP?N~p# zD7mfb{oR=ZPE^jgwD3G< z#8h1K&u&zKD4q*Pxt0ta#d}bm;QqZ!hFift22a~7c529SkmFQyN-*H zzQck2cL5iH2@d@Lhq4$~_!wMWL6(&mNq=7HhT}YYI$pVVZeQr>)4>qObE$PPNZ2!0 z&7?y_upwfiefj8-`B$ju)}QKTz*Zs<$Lb?XHBo(jyU(405&`EL({mgxA$Ov49U|rN z2@(l@n`1vzG(v=!u4AZ*0s}~H4{VgcNOJ1rB?Kg!=)mGHKWeC|MHb>aiQ4Qd+gq7|??WH7;?J+kYL8z# z@juTBhW#n3rN))N7T1~)qr~Es;2rln6_U>_Ejxj(E5%Cpoc^vfw64mua!ADSZ8i|+ zB}g?u(dtvesTegnG!9K33T)4eq>)>ZFp?L>R8Qp#(J=bxz2mscD;ZNoJB@ZUqPpI>o7VgScniW4c()#;@;-9PfR`b(r+#4c; z;1-)`!?b}4A3v^zVtGa(a;O%bzu(ZG;(l4+W^vU|a&n*xV0kU$uFQ!5!aWy)^q4^r zn!-6hfj79_B#>GGNvQiKMD?xyW>F&GS>3y?Ric*xp4cz3FH3Gd1z|e+Vuug7*Ya48 zL~K*l5zo1XRuWm%S~GzE4LQyuRsH1&L`Gz-%>!ZTYn9K_Ttz+Pa@9hKob^)gmLVN` zKJz}C50X$$>G1Q_p;%C}B?<9h`60%vwalt2*Ymd44dGF(oOa2mJQuPQmE~Yurn0UC z6(+5$posAd@e$nvJQFL^C~E0E4IH`B68)j#L_u|Ex5mNE8a8{>gAGcIFVS|K?g77# zE@R|9nR>Rw3(5}{d~HnPpooZ*XZC$5FYt20 z3Ydvy9t)XHw8qFCd;mt8r$e?RQ%MiUF@}!oDGG#E6xxV z=z>11f!msSqbAZYnSvt}&J+QXZCU5b`0!gi_R}Z@Qq2d2Mwc z%9aWfp&x2UGbLDvtjGb*p>4O(#}UE+QhYmf0&Vc_Ay<~3V0zym%`Lk}-3MOz<%)%#Pl z<=OjGrvuBq318+CJ-{30QA1-O@<-O!-zFNM^&wp}iWGG$B&eIYtF)Rs4;5FK=>Aa9 zyTJdUgpK$di~MI|ZC=Vkd^V6T5h^z))sl~Dq7~stg?&l_LW6N1>0nX=aS46Ks+vj7 zr#P2~h=M-LLX2!W_k&dv^Tm2}o9vK&uKMDMmPkEcj7~C78vw2XJx^s8uo(Lw>9ET2 zzXG^MDxZzwh4y=Hs@h^Y2$ntYP+GSm>#cM9ZiUR^>tiFtIol3wi8=y~L2f@Bun;{B zr@yZMir9Ur@yw@7ni+Jd*Oc9hFx zK$M%P9+XKj>`spPB?k6^h1pok(_k*E$fr(SnXlXEnE{ODRWuWqB2u+8*2z?-wl+WC zntSCtFwpr0nF!avN+7`^Pt@XDvec7%ipuHYXg%5TXDAXv;U-33A(vzDB8V%0%j-R@ zk!2mox%%pJ<_M$o0lf*YButy@IP%9Zz=UDDlr|NuSNW*bYB{&18Xj|$eVP~(lx>y3 zgjJh3l1)5_uw6CTgk`ABQVoCHT$nbFS*edKLAbhRxLyzMI-{#6H!q_O@+mM7#~@Kw zWFDq#m<+NGVr`grM*Mh=Dq@8Tzl-$WKFWsWruYa^v`B30wDORai8q&__SDBzc?K#o z^UN`hN&IN;bep+mS1Z}i#zurS+Vl`B&+6`B#XK@l^8+&2+e@&zII(kdzid}Lm^AE5 zqjZ+3N*0O?1%{glymHcUP?g3vB#mH9MA)__>pUakjX+4jPuRS$9mmbImM8^= zOGMzKSY0_htZs;&-)|di4DJjSjVQ}hf2vq`u?G4@2@M(y#8xp{#1&$)ZW$rlUwG%{ z-S3I$D5~^(7stnQ#qh(0D6TnSA5R2*0u@x*22u1y%V5wYfW$b@)H*9X9{5!1Gw0`$ z4^fR@T%cw74(zCoPNP98@iS+WaFoE>g!a7#s-iwfRHKJSou%<97*I%619(655MjTr z6;k$p>T1-|cb9V=`;0i>gjBf%t=3jn_oC874-1o3(J|G-g$c?a=wn!m?U?CAd4WKW zm>=k4ApUHFtra|}Wl_G|#Y@n(Qv*q-frfU@rg{K1dLr%5(jA(Als7lSt8bue+zbab zVF0VKb`8x4k`2s^D1=P<^mk&LXhA!1jsr46^sGC@bsZfT)hZq4gnT+I+aHp`_XRE{ zDgx9ExOOSGF^DuVB_iQ8s$S{7agA7rKLtYG0nVl0q1kdJPQ3g#tw9qL?gP!_e~V$R z7B*H7J0{kp*t0|SM#+|$l6`>>9*GXki2@B!1?#&`s}t$D9D05bdTLaq__DzJ3hhhx z4>Z*xjuhGkL>lPDr8KhXi~8N*3~eqgebLTG`3g)&9`ESMo4O`ywJ{RymGvLXG}!Y?yAZ!5^Y19ukC`n~3GM7)2v! zx|C7WvVV`|+~>K~FRJPdp3VTPY##;_7#_^stFuo>5ewhPn5=@ApsXs_<27I&gPv>g~?s5SHzci&*$xeFVsI6?MsNJwojSpg9-+xbDwNanO9CUPbs06^E~@ zW3}{)@boKx;MgISD4?gb;X2~Nzv6Vu z_d;=oiM*wq!ou(NN8Zrg1ZYYlE==ylKlarfHe9u21xL{BI8t!pRC1^0=DGRrV0_Q@ zC#L85xcROt(T$6-@Y|KI-@7cgFD>WF?-)WG5jRleK;pn&=Rb9nZ+_@Mx-Fk~VSb{E zq@Ay=ub)@s&Mz*$+FSlG0WrrMKZI+3YuZ5k`RZGGO+r;}6mJy$DM;>AadvNZ=5yf|1r(je z0NIXNIS||Cv*MHEs{?>y+_cZmakNb+;cq-QqDcP%tMf{NmoE%a zN}Y33Vukiwxzm0dhmNsZQ>TsfYfZ-XZJv?ZTQ(=j1nt6FMd#;_K1oqQ{yq$GC6%)U zZU3B>;dh0p{DE?0kaj|iKj8?vvgC|-pv7<_WZBV7+B?`x+~3_las0^52<3d}UOOFD z7O7yf($skvy4y{NCq)B!Z=x|~NnJN+V(IV6LPL~?ORfvDDj*}q67_9}bTd~ci zlKmqOV)pG2tgWwY4Xr65@I8rddMwBV71bVAeGxT?v8-f6l9tsu9MFYr4r+BQr%mT; zO=G1)NW}SP4_kI0273Ew)qtwOwo=X-`1?bJ^>I^-9FXhSX17W>;{G^F+<9U(<%-*JPc!x>jH zSpfzK?Tx3%`#8Qlql2)Lf)TAiKHBQ5IOieg6~2NY7g@9IFI!7$DETtUG^srTsi2YS zc$`cq59-bK0{Yv})|#O4%XrxCkS29A6q~iTWNRlF;SlDMr$~v5hgerQQg_UB>M>2% zI6J+NtM*`(N7ghI_emz^lYyF_O8LW&&6oX-gU1h39L7r@8tpHA@>FGx*W=fR6E@q@ zg{!zJeVuJaQCuA=1@IE7|3##J$1oumJ5vky^UJEjKU#$)KuHS7B;vs(wJ%$?>4zlr z<=b*ca@HsJ!Osy3xBOqrn__D7pqhw2^7;n0$R~Z;twx??hrssk#C1cMtRHfFzhTG1 zE{;!Tmiq;ZD9#2W4(M?+!*~v>l$%5;__SINKTNAEIBf46X8185dhp4TD9_K#gp?em zl9d>E%I2x(q#pB8rt!89i!Mi7sMMmaZ?N?eM2!JHoQ{QdAoSm@`@TtaEkw{)WuZe^ zzrVO3sL=ewi4YYv1t!gfQ_Xo()Is9PQtqh!#?v&Mscaiz6wb$F>GjZE1xw7d5)*24 zu~!(MAawsNH*G-kU-c=3l(?|JJl0^q#LV(WKmSHC=#5YKstmI(V=6c4>73kKDwk3F zD!sjK#(*WYb8j>uP??1gq4SEU63;>Pk_#yOYu7(GAy4!ABPQY-WoeY1I=l2&k9RM( z;&F-Ki}KoHAb;HXNP-^_3u`-L$+~dmP7LmypyE23q+IsyIAyGbu{1T^)Y7+m(;oN@;N26N#9X<& zwqI@>wi=7v)<%`#h|WWx1pPuT%3Hx zTmHj4u@(m6TMc`y;_9#P8As?uJeu-!|Lgzd>}uWMUo5{kA<)1ndxs@UZR32fT6pJHGaO!4QH(eAa5+t zS1N59EQ1r6i z<(E$QmAL~w+VkGpLI9*Hnm0tLT@_hjW9JWQXev%DVG3YZJ@}x78{*jc{asC?1L_)h zF^DC#%H`1`O_VrpaQ}@~&1zbs5~&ja^i#ZVXwP!}j8mnEV@;<{Ahw)4%S3LKNFJ3i zaiK4p7j50(Gg`7o7JU5p$cw9Ok3@$*lZ@g;nFZi|2gmE)4`U4Rnm2m{vKk-zbX%kA zCoK32`kIhZtyUTzRW&2mT0PG|s|zU{4QPllcC91scP>F97ZXap<9Bv#F$2P|qk;b&2$rxv~0fH76P8hs?SUZLs6n%pW)x z{94NZ^zuBrMOvmx1jBKr7I^C(e7yj;&kgD*7xRHBhV0n=;gNznW(J%ArEdQ3v2RnW zr(kstOqa&TJ`*F&kJM}we0``YRAQ>!`T?;}wzZgRk(fa^)#2*9%Z+psyrobKU%nac znGGN&)Npn`s=}e$R4yL6IsRDDSF=Ps)Z;1?NH}K#C*jVV4dx0@(DMhJqOL*I6)&L4 z9cLFcW!bbaiw~-ib4#2tjht6tOE}{zD6zU{xlC2$ zI>jGRD=rdrA25&Qq4jqQAhS4A^TEeuR}+ZLmIn&KRN3!3YkB-ej*-b9-c-AE)S%N> zf?x6evrm$2MOQ(b0-<^gvSC_6oBe@p+i`Ajxy1G91_dbm9z>* z`v6e3>~L1a-C*c2`$0^HXjr4(?IN{jFy+;}uvyb!LNh16HAJ)d@63e8GRMmWrMZ&F zv_aLU&4#ktx$@=QM^zZSdGAFn^&JpWIEc06k(WFQd*!&PpmY;wf3>)TvXQM+vqd#z zyU8VT;5@(~T!27u_1N3Z<{-f&SNd-M>^C*BK>cKP5&U7*KXmq@FP2FiN4aT+-1iF~ zfRiPbO{*ky%`uehvD+s~XnH7V{jvXcN8((ts-<3M-#N&I$MX3xlZ!UGg+fiN+}`r5 zkj3AjM%Sj6BRHE5?Q@(GmaEXx+0)r!TPtcgyrsy<^`_Wc*hwyr-;OCdQ4#vF=h5Xj!r_#p6O*Q* z)GM*S@GP^XHnavtL<^TD>&W%F)LS4nt}T73^w2{aE8S?2vByR~WOdM+N!yff<@?z8 zI#ww-Zu3B+Dw2VJIAV7nOX9!ujfO>l`;d|vXtw#0QXN#ak`$I0n8kN5(2;87J-CD? zHmL*sL>eCfe*GTXwvDI2D~K%nI37JKu}-!Po8ExO7L8{#pw*RuB`6KEDkQxqNdG4R zbz*yTL(6Iv2z+#WI#BgSE1!LJckdfI7H#~xxtSQ;JHtJbofI^}g8L7|Kn}2;V?6dd zK9bChE}t-w#v@|YYe!RB4PsH{@hW+RWHlR3f&YL23-N7 zB={^p7mTZ^ud}HaFV%4UvxHK!)luf%KBVaoi+}5rSQwa@bCw;vYHCGARWld==<7kL z=59v02kEeG3Rm_z)Zc3=MXmaA)I9-9T+O+St{6L3)`@2_41VCAA&8E3bj5sZx5x4s zmtI{uQpw=7HHzdjnUy|za5p(fC=*%NXWhuB(Dh_u6(6Y_e%!8tO&OI$^_@sEYZMc) z<_`+vf$U0(c!m5aMnvIZvM^uI5SEj)Z(;;xrCT_CmpZM4!RQ9UsISG;<-MiaiPA(v1+;q7waq z#DaO&yeXX-esRlYcP9QBezojM(;1VYYslzFHa5kqnhTql9tB)(1PR83ymJM)zr}u2 zA!bL-PF~HWs6_&|a2T`59w8gMCgzI0ZUSUfQfl;Ojkd&KMV<)NhcnfxuOH2mUXuwQ zAM*!OvW!{`MXjm7TIXfL-k+n%0dP~x1% zi$3~@96_CUQxT;Gzf^B~3kR0u=7eg2I4Fgw5M>k5m~x;XrP_^xUNLYFvz1}cRTX7r z0lHVaPz&tCq!B@(_+nwtq0RK$#IV+@P;sE{>RX8Bn-rrhrkj}46K*PBvhLdC@?i7h zJjx#Hk>f+3F<_Y0nGofcP^IE@)+(L~Q4*1fl-B_6231_D^dqI(^dhIc= z=LA*Dx+nYb(z7F472oY=W@o*6`ujtJZ|o#z!EAVr%)^Fux|HNxTtvhvDsp6UwTFwJ zM*F1zvWTTAmTD7v5DPy;dkkH$be+d!3z!mh9?~B zP;G9Vwc=}F40A(Sds~L)9PeFHO$%36su`>ADF4lttX|1!{}kJEkmfex*_yNVfSVdD*&UI|G|lX40rxwlAPgKpuk`23wH2sCfRuKK%fnp1R#=<@<9%+; zML4y^o|%u9_V0m5cLefgy9n<{uobfvYeu+aZKo0Ktc|gWw&pasMBNnfI2UHbKn{9O z)8)imqR}+@&r{T;xui0wrvTi{YW)CT-RWebe0G8{202Acf|Llgnqf=$=%XtXfK4Qv z=zT1j1nI9*CySKsm0?}}<#3SfXM2MsnAkgZs>SG?0o-+s-LK%L80d)#K;3u!6;8=5 zX@g4Fm=G<8m!gGW=R{0399feKC9Xe6!If(%Vf-@0mQ7tBX0NzqmY|9qPu^277yohID3?W6U;XA5NfW2T%outqW~PhQ+n&nro#DcM$Z$THW`N zvNBz|DwU7qm-tFK?Q`5dA&PTB@?7}m0eDq==POEw^{A`Fa?qK z&48UqJjKg|to+>?O{Xf0(K=JOzIa?8#vDp}6Rf^uG9;_RQ>Sv54OQdMjViE9g742S zMhS8Ye+*}NihDGfGuOzbNvx`CgC7KR%vHu{O-ehz$6LT4Mk3SiWVM?^5C{rNs<(ci zqw`nSS8I-1*=qA%mSmm%)UgQ`dsW)FynP!Cpz`|ATE_}k?|*Q37_<7=60FiHwB(_h zw5+MMx={v+RgSy*%jLa^{Rki@+7`oxIZt}@^zY`)n@lMhgAPv!!2u;Sa^;2L@?^x z%A-Mrjx%teimuzTAPSO;F~lr&gy>_G4IY{^P*NEOF|%r&ntw4|Ix}Z6Za4>|Vq}%A z6pcxIPQ@tDsnqjX?bEekhr8)RQoOi)#Gg%k8s-M;;psx6&rT16qf|d(x zQm|i=dq2&*4+`a7Tfs#LSH|);MEHt+!b{0d7;B0PK<1QGH_ynoq!E*2hGkz#6O9hV z?$@wob1i#9kmr+^>ORB=Br!O}1{@=Or zo%h~IPq;QRxJrZG=B=N=LCa3_ths#xboN?(E~BHD0#-A0HRWBd% zQcIeW%y@>zZ8l81ks#C7e+hpvP3-w#+7K8!Z#+falSF*kz#{e>Br}RGNxX7AU1lVi zBM!bs|1pEQkrg!e8V!3s{|$r6OO-b5{0em=IHTj>B%>xTM{2fQAz|zH#Py4>+?xni_0O!81gn!QL~C|A^iO>kV^4a_%tZvJM}($5)k4nG z1`n!DqAq7NrQbVbxd2VW=*}I~?A_RaioH~%?eBYLjJ5@FW1Pu+UAm(%H!%U>%pk7} zejlDzFG%i?NWK}?hzUWsKEW}sW!hRv85emvYXb>bj9PjkEJUSs#y-}~vu{`L=EN&3c~hF@`6?yd zt*{wD)SEe5tJzqXKE$Yy+1IchWywJgfw_Q4!wv!!5v&6E{)Mf7)=|Ty$5R8b@U^UT zH*#GGHSYPR@bGZ$75&;Bj!Dh8Z%`1MNltRwF(-lxD(>)-*7(HhmG5nQ+i+Z`;k`|g z%h9)2??XolklwMj)H3$J>HaS9heUSwj9nb|SnvxxR~23MWzjJ&wWNu0GHR|_`D@uU zJcWrzlRcU6ndDlgFI8Lbxu<+@@QxstO@yNH$yd+_nh{q=e4eP<==cK*H3z8Y(t_9COqt4~v_Qlm%pPjo%wZFKfn|@@9(-C_ zTK~A)tQ3f~*E*=hg0)-;lGt;ScvIjOMibwZ4x zJ_UAlwx$oR%6XV>upP2|637WYo24&Q}Y_fL*yf-Q)J=sU0Ln?t+}=J zO{6MCeh7$_?fo>?^zii23s=e9C&jWN+3Wk&N8il?$Rn1TVg8b_3$+-c4t1EpM3jNP1tx-~ZtZSw|kM3YHhY<3yn%Vn1xhDJu% z4Dv4H$I&nplNH^mY?|6wy=hopGrWsK{z&zWzg~2L(?_BXd*1qJV>321H#9~{E*{+K z!e9TFLZas6aujoB{o2~V*B17dvd{&Iqsk3=Epw1yoDK19=8B`6=j}^sM*D%B$mSlQ zX#nr4DX~ji#!=Nj_)ias_^{Y(lA?qcE`a>{=4^TOc?#56oiVbq2ANi8i&=TNn?&pk zt`VtbWh*T;WGoa9?%8a=={cj52ay?-Yi9r)62hP4b&xzbC(HecT>GQPlc<;0Z%*7x zZodr#pCg`OB3`dw!hrntXAoJmo=QMs$@kx$r(LhAPd=epl?(E@ zTyv?TwckxHOeIZy3=>WJv}?OuzDp~badvrF4_ zZAYU~d}%i=v{4M&=+*K|6X*V2+1Qvjc2Ko9YD}ENS~}lpu>xTCv^#n6e-9qt zhV_&E$RMR>%`RQ@$54%E!G$j!61RAW5b~GSPP)}#v)oupgLY4;dEuZK@1+Gg;XV}I$rIL*jyWr z%#b+Fa2-|41c5tm(GN?a8dVl1zFisqiPky)WPO?`%oSsK(Hf&IDaL(r`%S z-2Wn#BoRnHfqGV*!s*;zG-l;5+rkmw$u*-sA!lNdlNI=^8=bE^h^& zEODXG-PWduHouXLwjF4F!(35IXa!Q$a@o0)hwQe^4f(f-JAX*4-Cow;VDb*TZdS@H zqUd9T*+%su%e6L7M5t%M=UJ7V9HyWKQT0MWs3COo66`!uFnY3gmQjYiy2x8XhO@)> z$~WPw(}UW1aF~-s=CIaPH+8kG4exyi}ai$+h{shB*3W0rRF7=mD$#s zvR#Q@SDXD3D^=`Ph`BRQ^{vl_$cFGe&)d~zCy%|q@PdImLSty)@pAQ1>&enPc=}Hc zxK|095i`i|VQrKL0815&JK&dK9DdZJTv=}cxe}!(rRTVQA zz>Br`kSb^ePLUvOWki3xxKlM4deNqbyEV}je3vb|B;s5&FGql9?_#CDoYdH0y-F&x zmmEfNh6h@>F{QJ{ho4NR2lD=9hGNH2oIC_rb$IML zpQS^1(_7Yop5+Vhy%+YHF|E`%=bc9rjv2?=;WM~G<|FyL6?u#%TieI6z;E_?35N=+ z0Ixo25mhW*iKUS!M5jj`B4Aoh4{hmH(BZwuOSArZaffRMr0bkL=(zyx)q{3nGIFCt zP?|CQYOzYk5rJl?01bIJjV$ahRJVSWd3!3Z>FXU+^up2{FBnzM>P|-;XGsVkL5`RF z^7=C zeC2+{=kIBc)0DD5`G_YoUabnci0OMA>;XphacRZ#+lS*D8?ARGW7fDCOLMwkx#)by zx#YDL*_I7FjrWyjTBGud;0GL)qpsT(*rB1J-_=`Uw&ydA;1-mYlcj^y@4#eC#Oae{ zJMzbmnKyLiYBU&+6!x)+AHU8|r(4I|5gXO|yvLXkB8XQ!H zX2baRkI_{jpLFvC2dRbFcD)-@6RwWk6)$7O2aHGPQ4w5Ljz{X^ANl66!{l)US^OWr z7AZob!By7dm7H-cRkSe7adHaySI*vu#vJk0AzD%0Oj~;1NL0@B4>hMui3vafOxJH( z4|j*!N321k^8ELv`Q|voWIy=68f3oF19ight;SN>tLXSx=j7MN<#sD^G zXN=O6OXa?}ym}R~{&5qmA3br7O-gH%p>*6pf0>seX8#r;TT_si#b~RwReA-by-m5@KaM)U^CF;34yDGKb(cEIZa6%3o05E4cb7* z+;9{Ba~%6OZ?QP*qY4Lw{;`lW{Fw2)eDG(3ZA~DV=!e=H;w!?-D#OdFS1(gG zyzFg7o63quNB{kdv#R(Yms~Bi4g9(oQwOYZYF`fcDwZ;-e&+u6T3W7QyfyOLH~hV{ zcv{U@RWmFQUhZo-NV~bPb^B)Ma;IYLenRx_^`LpLomh?w_P?t)9#vU4oFt$%US2J7 zG3u77_b6!)XWOBm!OJr?p02gOc^iVO`vx^92i{QobuWO~{!bcylk#?ZolipoAuKZr5iYfc{YDSBTuZQWm0!K#TmjNYXzrs)cQG&h zs{O^UW3-$Pb6!s4t@cgj;iXW3B7S7t=z3bJhFpwR45Ez8fI41>sx74>ekw!_IkXfy zaL5ml)#=(w-DYW8AfCLQ1e{;|xE}b|M;gTf5I`}KA*Be@mJHPc`IVnmN zKzM}j2YhkQ(rua?wS`rnM9N_)A*)+I#aruc65|6j1X`K72zoM*5Z~k)`YpJg5u#T# z1UnK~t?@aOUqv`d{*9m0_V4EBFisI{SFXLr&WLI~tQ zdF3Fs&^^1nyLsQF`roY8z^SLRWCE{Et)_#r$;h|s@RR6~(s*+?KO^%8-RISZ$H2>s zU{yd|BIT`kpIB5PjcsOqU)MkLBt+l-ru8wdyMpf~uKXlS!ZkG8fCc|ZBT$+q#M{LXUTT@!$(pFyi+Z!=WrIl!ht(fbk6;GJYVD*)Qw*}LClLT+2yS_;POgF zq9xDxnSU7MfAAHf5i3~pi3m+?P6Eyb=Wi3&phKKk`PYcAC-FI3!sn7~p9jc`Cj$Q8 zuHDipWtBYU8|yeb(Ipdt&#=;h?}Loqf`0}UBZ!p$r;RqQfsXP)&wO+4Vflp$K6?&Q z;twAQ9bh;;J&DQ?%~cJxeA4^Usg3;(?o`E|Mm8(tG|Ayr6JOM1hW!Z zqxD=krm74NT!{cb)MHL-r<17RXDy8XM(g;r)EeD?j?WYa&0OkUiQjcxzi13nL8K!H zeDiiC=kH~xEt7u3fCSK42D#NOh42IayWdgWtoKjlQnwdQM6un!^>Q};JNS3NxvanR zz__R3*d{xY)ysy%#g0*R>YHm?_pI#R?Qj044R??sFMD2~Kf4zvu{NBA_$usENKfTS z4Gaw@rs*oK9f_aLy@FV(2ZI);S8rim-Z8N3*Dz@+q80$8+CUpR`}czcAl9#Nm*w` z3|4wuio*VcAN5^%L%@{ESF$qq8bp%5q0YxJqK_}=U17JDLBB@&VnLzg8n{M7<51&(7bIU0jO&t zore{7s{$>&?z~!j{}cowSNOHUwt9R85(Umm&g{Vt?c}9`e7nV{JA^-{`()zWc}mP< z`6vz@TnCDyM`=+5RT8M76SsxK1reI)_I0bypU)^%KHehFfB%DUBrq5-5*yhuSmA{K zg;^?iEVP{?k%jiZ^P{_rUv90*a`V}0T|DlP7nH#NEk?)g@D!tQ88(Hzh=ZT!Ipr*U z`$%5ehv&a@uTgn1q`VV-gj@&HX?$b+@rmi(FbA5?fQfs@S1S0_0zft0jJDHE{%Koh zJ}Yt3x&j;YrLThxA1C?y%Im9L>9sWfg@~pxH)IpP6d7j^Rp84-`?w#;l8_>mLOU$b zsHSafe6DIKD~U7^dD|Fa5hAcEABzc6^Ktz%I<)h8d7rUL$;n|Or^b9< zreSTSTbv4S4e zb+4F~=Rivm>wW8;?bgzr-caIP$LEvo{?<~D?wb*f zZzmBM!r>(u$Kar};P##{zdSDu1fuBpt zTQBv*X8N3?HakuultkMtd4Q8C_V4LnBc ze2rw!s6?G6Uf98Phn-$ud5-UQXr(!yslCjt!C&F2N z42*250>QOtI?~TE?4s8%=3ts;Mezd=8L2BMI?lDT` zd+-%YaKTWgiUykY6;X$SH8WzJweL&qkIL~-{r2?12=un^tCjyE$j^eWlG=R)b31$4 zkO%>Vx<_(5UEW5hTP8D@Bgr(i{ZlwprU{UL2MxN=FqS}t>rLg&(9wFi5&|a?mrz&# zoRbHGs<#$=Op@a|-xV_Vm;kCqZ$2nWvjFWH`@0g7A6!LRVAWKP@LcmdKUJmGD^juJxC{MLX2GZvG;>X!!?68TZ^|$=XepiPnI_ zw7cM~+XO<*d*G+10HH=PNat07nZYlXwM@rPmO7qLXF!Qson(VS$82|Sra<}4PZMZ7c8b7fmPo~Zh5UZ z8?C7AAgO@JmB^Lw$JuK7FPee+iUh%!WLW-D7|TxUKs2)mc23L(zxnOpF{>7~e|-~t zbXysjma)vW3S8&i124Twu-3@uWC36HbFS0tID++G@BkdO@4}9WIp8^;aod!0VE$I4 z5;fO>p#q#OGeyM@^ah^>oA=vc>$sD!WAYKOo00&|IytaQ`xdy*D`N*(3eq_ZuzOw$ zIBQjakA4H}(SHCUoigxU#Jzd`lQpGIf8|7aJx@rPiiDYsd|b{%#vtYR4|TP4qD1Ui#tqq>Y+bmSmg z+z30qxeji#D!^@KHArVQG7@eAhbcu6u%r+A~fUC79DP7T;iz6qqP>aA;GauX-0lUmB1ZVAH z_OsO>oKgUmQ;vh}^my3zVKK~m?Sv9DSJi{!$pfW;*{indelQza2iBidfaQ!sAexo| zPK*$(r)0pcX@wB7vWcC5TJYAZW`DlNGS@ng&Z~hyBLySeI*x!{=iCE7!y4GTv>AMt zmVuXk1^f9L2wK_(A#2#*o0AMKbJJ1-)?5j{o7qg$W{F&hT>Bxi_OzG<&uGuwKfjIf z$8B($p21eRx!}LF0QN3t8K+Sl1g>acoYKfv&v!w}2zD;Lm^6TFX*IadD*~B*3&<8Iz)iOh_N{4x&{fS4xV()0>{SrXIL-de)42zC zT=V_D`JV&mh9hz%a_#%5IRC#BbG?4r5j;ncCegYJHs2kk*xSgs93s}2gYC39u$_8}eepBkHv2-_F}GWG%{AYX9!um( z774GGer*__v8MIZZRi0t{)o=TgM;mtgF{f1@A>Sz*Fx&rV%=tyvBa#2@k$NsUcfkLVHNCNR0SThtHEXFUGQ5}559VhEa7VgnO+;XOl8R) z%Wx(0a#?bB4$McCF=BOQNu+&*GB>nFO;-tl$tt@+bD%d&8R!Sg)$+h*Oc|`77zD05 z=fG#tCGgZOV8n^t5G*xc(g?vTo4GIKKD&%d**)j7>{Y)Q0*q_GcafZ(glY&jsRQqM z)!@Cj7`$|=A!5S=kQ&?p|CQIkb#@k5Pf7rLmK{rG+yvJdSHROK^H{-|CMw+`awT%@ zBWQ2>Wx)0DUyZXwKRL#4{2rn<7lEzz2@uW50;g%|u<6SquzBoJ5PTL4Zu7EX_mb-@ zfvaYuSP3C3Tfl2!IUHQq%CcF;D@!W5l`_f#vPDg>Tfd4+@?2)!WB*nO$4%~YO1av6 z|HX`-3`$wndx0f!=eQ=RDFbDU<8}*PQf5q6@yebw(48^63up|Kz{1zkz~Y^H*g5$u ztp3awJmzJAXjTqe?pLw{ui~l#b}z)Ge=+P?S`TjX3&C;5ZT98Z7uKs|%l{TQAW*QA zQ3{?5%D|nyrS`97ZxzETkSr(!kA;`ObzTN+85<27zl>zr@nNvlJPndr*BOalJbldW zu6yaFmM`e$BoKNp?wt8yTI}ZU_T=vV6@1xJ-`n6Sm`~adn_P~fyN+s9%uO*1JRQwsS zy2CV;K){ZzwL=TRdSV_|>*_e|G@89Q9&<}rdS3$v);7U@(+ZF+$p?GQR9N%L0dSh0 z4i*|mVaMbcu$dAM`_~jgqII+MPTY@kTN}S4J(fV|O~%z{ny00>v^pL$ZwolGwgY^% z8$dj*7|f>zGtxW@J2ayi+2+IMua3g{&%;@gbp!&J-GZ>yb&OL=S!PosuYp}vM#mDC8kv z={xzL#a84DIWH+YwACWibOs&j&=}|mlLzjGDJs6O;`J-A>x(9^(`HL|ta0Y3WG?Dr4Y$zkNVR1QH)TfuKp4eVoC>%nyj zmd!RpuyGR{SXU3nEf_IRJqs2SPO_651J;w0!C`tTh-RmOn?Wkei0?p>umO%+)p+L} zRT#9^|D-}UE`h*b)D(8Sm*HPyeqc>Wc+`d_aQ?g*Hmg^{mJjd3?!|Xt-w>+`8rkakE=YB&z+1l(r1Pu5XUQGz-?bWl8CI%Y<5uLF1N{Uq z^+f2X9JJI?J;Y_Ls7=fnbQG-LYhugy3t&GbnH^+2OSN-BGQWhqL9isEhGn1C?29rY zHDsi^t_^}$H$a4W3xus}VSjFffK_tvSyT?eYpPkwUkSbjmF%Qd!#?(Nht`*a``k>h zo0I`A)3aF?n+|3Z!eFP?aR^va0It(2!SS~famu?$wP99*>Tv!5>mAH8~(xn2clZT5LzmBLKbNSHi8lK4_j##EKS?8yVYQS@cx z8UtI@8(BJk58QM!VB7c@Muu6O*MO&P8OuPM*&BjouZD8i%ib`7#?`Qwy-oHQGcsMt zvRn3630P6XveibAu~hwlNjvx%RKf10g>Z093&d_G9T$tvD*Eta`X zRSAG)ujj(Hj|xFF?+kd(y9{o#&w+Se9(XLg12QAbLTe#JAO|n@wg@s|>HNkPh}iHQ z_%APmgY3kFnKi=E9c>V{z6rb+-G{I>55U{75JJ|<*$FIV+3g*$7=Ik>7`g5oe+F#7 zP2)5YYwZ}=FDQi_U)%+UcOHOX=zS2pQ4YIjH^I?O3fQ+)9(ygaV=3L-1VYc?{^iCm z4sE+B+h=k+9B1z>`!F1|RS$si>-lUMUceHwIWJ|MP(pmNnGffMmQ*Fhmh6v5VEQX{Fbt; zl##Fh@(M<}b=>MXbWH;U88t$vaT`cMaayu1HPo zl;i_Y(DA`h$D1ypD{me?wBar+dp{B;4R8k?)o{=q6wi{NYA{i|3zowhz;0v{h{v{q zNcSQLXU4tDCu%@Zl}3 zj3XLguW==W7`HI;t>@}peU=t;yc1^H0=v|NatLE2(x0wA(h~} z^ghQIK`ZMZa2fk`c|H4mEd;V|-RlcWEtq zTQozcNi9Tfd;k#}+Zftm?{Yb(vmW3269lfR1liJ32wqbLksBT`(yd`{mPR47L&PmDOIx~kY4K6{@vN{ld!#?}nA7SgTa`sj%0+ZM8 zv5R;X=BUPij>Ic;2MIby!)824qAEbuy95) zXulzaZ(g;5X#)dU*6POX(M(qjWzT0NtWqmvxB*+$tHI{I1_(541vlL+u+%&TYrYJE z9TVfhW7ZXLoR$vTzfS!B*?SM5s+P4~ch_HMF9RwFm=o$+>e6KnC?YvXFs-%se{Q|^8|^-)>fZYAxqsSwuQ0o+Yfi=-a{^;_ zzx}*lf87HKx_3})+mEaxy~wugWzd#r^on$%pY&u5`8Gqypkuj5N0DaSPa;Y#S^Fi+ z3W(HviA*zY)h9un-fI%^cPKeNgb=yTo&?n%xj+5di@w0EAg7f*2vfNMpS>60E7^iX zy+@2*Q}l;%+GZT5k4+-O^gSZ!c!AXz@~jB$P5an|NHuwl)7BqQ;xNrHpL;F!P%m-EKEeG>UE;$`*4-3ZLLnd!@JcCukz}DunxbU;%kiV zJrSwhQWdXz1N(o7VFJ42I}Z|69|kj9zjMMadd@9AlAVdHW7I5Bq5#jQ;5vzFvr_8vpA`z&0FY+u$3CaeLZSfvC zM+n^P`;nmEjU;aI(UCzC(>|PW7-7yh!;G8c8ep;3Q)Z(`IsA4qT(8UgPrua?q|{&@ zEPJzui@nAkxJm!;019nB(8w`BLfOZH&m5t0G1e^l=Sxpa;jH5*&e}|o;0_V3zDJek zr*9XIaKF@PjD+_Uk~JU0N8$=R_B7-8)+z)@cfeb=0rC59BSEVVfg2{^vT%&Z^&u?h z_rQq%J~ZcCgx1_3QKS1hD116WILSaY)RFX8mpVcL8iCy&Xia+-`atxth&? zLFD=dCxl1fw7eUM>YS~A1#bc+FR6NjD7C?PcO6`I)xr9w5+v)~NB+?lNIpp7YSNEF z>v0qxpC)Y>L8{?<6rC7D43RIFZIo@^hg>4md`nJDhnX8rHtgYC^JI+v)1VqB2>j`{ zUV^sW7YJ5t4T{majRGznLiV2{(cEK$EEJG__#LuLhfwS|fl?CM94q?S;w{dc7-6sH zSq{?$A0#2}qvLN-e1Z!T+(v{-7yPBJ!%wOe-qM%p%V{JPMZ|U%_c%FB}&1 z!&2}S)ovOkTUl~2w+}6sHYPqZl15c8HghRS0=wfoPaIxf27kF5aFQtPED3q+@nP@_ zZz(OW^6I})uUGY``0cAb=PFy;>Lq^;G6Eq)roOCC{q$!$Y@gwdT{C=1SVO39xwE?K zJ3mITTtC$3?}P#WHI{;9E8Gje??;F#2a#ra2Y!1m!$GtHZW8BN*e^)tCQfXtK@sUf z?vXdhGJlJ_W1NQcp}=+sXNgYpkB%YFx}P*=l3)_jb_wjZZ$N84(g zeir%D@2#{(KqSv{pdjf`H;p<2$h90~IA7^Lg?y_K78c;dw8V7`7kqv}h5HzaY)4S- zJwc<-2x`5)&?xl*70#nLZP88k|1KQ2*O9n(z-`ZE1S+&3P^lRyMo*EhF$K?6LvUKq zha-Y7a9H3W^yjs+g$~lQQdoFEj6{~Zn*z58f*Vc6W^f~}2lg$>#esDxY&~)QVFMU9k!Jcgg~lo1wBajQWi$392o&(IXdQEtOh%osZ$TfdLBHDu@>j@S|AHz%Z3cU8Tv8Avl74E}BvL2_bA0tU?5Z-GCVK4lS z<-D5AzXP3l%~0hlCrXW`8p|qYSGf4kZW?j9y&JioxkkXnizMdx!E*CyBp-N)Gp?^A zZeD!D+uD#<|FCte|I@6qUQdD(_TMK_y#oF9ao9P-8(U{Mv)!Y(y7kXa*!mqOpeOPD z|2XjN_)I?*ca@qE#~dSDDnGjfM*I(PRIrBtXb2}3_9I?-nDpQ|eB~~|RxA%T+ltww zwVP-o{KRg+Pr4aJR^2GJ??WNcYNmM)k?R1m&H9mVJ&e4gBLrikD03yva2`YcF><&D z1Cv$WlTLs7qm|ra{pQ8TCwel>-Xg)^InqqHT(nW-+r1-vA0)A*3*|C_QujfWoR~l% z;eIiVN;MwSM6W~0F@6oZ&6V&LZ%3$n7d#|rgcGko-2NMgP<;*mpN8PIWD2%I-;$IK z`ENsgPA$u?6PpqCO+aUId3P~PV7XD2YXssmBA5Vk!FW*;+e2&f5vbZgcI0hVvHSDz z{s+IT;&nD&{iD>0v5)`KakftHnAnaI=uJ7&6J*Gz(snIYIY(~DJZ z5^L*s&P20b*h1%Uiv{*@uXE{FGXhztfCHPovvZ(5w~=7yCai^@!DZnPyw?vPQLmrv zC%|nd%B{e3qkiosO3$TlAyBp*sRwVP*zpxIEnlL{X#zE#pOJ4lOcXneT#F$R*Vm}< zqUScqv-e` z%ALkh>NJ2_mm#Fm4pGVv;3{4RFWEY>1aA>0{T^=1`*2v`4hic`m~LP;)3<2AAMZoPkykwxZa>TM)b#(Oq?z=XSGs)cDY6?wDOrDRLaV}M6a{uYD03ab zS*Ly?*g;ggllZ!gBGcd%0wiw1aVJ>^>1*(oYC?c)8&XZlQYiMqf898o7xt3{c>puA zA$oJ$**(9wbUB@qa8E2+*V)qoFmqqM66ueBR8kPIYW)P=W&4l8cYdx zP6+qIZOIT~l*W*5!rddQ8IGbAu-$nUo}$fg+1?E2?M;Z&xQDaWZ;@m14#f_`k~>HM<>tuO$W6mK!B&9|Blk=|5v9<=Z`&Q_LHdg;)2rysBoSjitRy-$0W`= zzQ;xXG31%NMyUK91WP=mFQW|}VvUGUe1I&=yGYW1i@?nja9lXRtcMX1tl|9YP@H`l zDtx6xsu}Dq3R1IU*`vaoEV3+F)Hpm@I6#gsm1-slZ5*5YQsB#F;R10Qouy`S?@5ID zrXr*oJ;p_sPZ4#2<35A0KMM0YDX;z(Yg68P18=3~Mw{)mIIuPg67zhqWrjT@=7g|# z>aLkS*iCgid+r5^*^zAWN_=J*#AXN5InL~L>A&5fWGBlZk0kdO%*d4s#c^3WYI7=K zA=pd8Is~VMJqTVuf<*2nfd{(~CVvY-vbR{ydVtJzSZ+LvK5*wvIt@fM zrS)12zn|peby!~gP23IO-lx??)*q4s74Ka3lx~6f>iTc_sk3~ja*zIyntKx4W;hYS zx>I{6H%EZ+(|0x`s6?@R0W2)QCbmdyxv&5ibL9k<>sR9B_&CAkZkr;{m(9eL+v%TM z@@gym9zGlTk;>f$>hKe|iPs}V;|)&iu7KOFD>$*`0wU#}A>ZN!F8B_k+IIkD!X z#@jN?pYuWh|J8CoA0kyA!)@ixBe)##5p8k5px*Bbs@#Xr;5+&^aeV-n-3{;*Yi3_e zIJa}o(RWBv8-nO2%L-zkIN?dw->U@4S=c(d< zbE)(CY+mI)-cxAbgEF^%BH1xC_>Un`^AY?cI^npj9$pen@Yr(&?oxHgws?%x{iE>v zVU$M5XE2$6m&IOn=3Rp3ybJ7$-a9Ls=rsT;^9sr4L@+DEG6-h)KxTFlqg!r87nl30 z$d~&qR4_Y*H5i#WTnbk*l=!o$;dwE-zjznR9Pr%J20t48(v0pRVgGBy z?3#k@qDMF;^csf*?!rKzlj?P-&M9Fc%84SEHo~nO;cN>RfBlvN8_DuqcQT=k$6lgS zZgPtwRT(~_T)r6Wq>)^7*0-ELMzgcSuwS?l#}+)Hzvm@RYP2I%qn6SpOp09e`%qBrIz;yW8DdnPBShv7+;%syow6boA0k=r2?~z&Ax35b zp=-Y2m|!eT)pMu zrPS9JqwhcR;<3E?53LWc_iXf0ZK^M_8cqw5y9w=udC(JRf%?2MYQu3jxS$15+SlMM zc^g{%wbbULAwJKKg#~ua@?=80W2P&1&T@z3oKULYh<59YZ^yTP=fWm>C8=+4E3&x0 z!Q36WzyIX`xk+Sh+fP0ICRhkQh2z3r_-=WJ48s9rnLLA=< z*Xeon?_J-%8WavQt2w2#+-t~gdjlNB>qsb%LvBtIOqSe)@?2{BWZ@k)JV2hs3wV*Z z%FRuNq<|k}_(R!b6_-*aKQ9HlXZuj~BC&PHZa#PHne9u|>I><45%k=Tfrb>{$-hBI z9Lv7pM3n;;4o=kOl|xsc9)|_)v$RNuMQ;!+(T7~iK6aOAZWpXj`CIUn?3nZxZFSR-cP2$@68=YsvI;D0{w>EiMRz{M;1C z^QU0zOnVa9lThSO!y(~j78)=Tyic~ukKUKWNLg!nDgu=*AzZ7mChJ&NTIac!3Oo_u z)xSs03vKn#Tov|SdATR-cAbIdl2m9c%76sF7c_*5p(AvWxh-{pBE%?UAp)8Qa(z6t( zFK}5lGP4ueq%W6KzL)xo`n*c$^IwB5|0UQ6_rQPkDAF`PpxkK)soLG}mZIa^N`mAB zoOp57Ut0;<)*}!l_d3W=>MDHpbi!5a0>ZT~Am<&-YN3?2! zc_hH!LI-klH{Fzp3Xg7_wS9}jYb%&w%JE0B39JK)>ZqMZ!brFi z@tUuYsPPth!sj4HA}S*gitT)MM5r!M6;6k&z)2{~r}jNJjE=ct*KBueo@vEGV%%hw zvcM_q;q#`?i(zvR9F(wyIOO!W%7q5B1kS-s_#Tc4y`cIEUh9UCa$pFjtRBEes;MpC zaEKRI{nam}m3uDYw)=8{pF}&Nw6CJfVG2<)18`qDf+Ki_%EeK8r*& zi>Ni7&2Dn3S5kbD*e6)Ph*f%SB#Wc&nc+{PaR|{Yjrt4oNnAr%I6#3vmCcMw&k2Vp zpFdRQXG29W8`|^F!FJJeSS+~@t@$-jqETI${}hpNGE{^zpeRUUyCfd=d&-b*dKcdE zHO(a_Z#a+iP4PsQSN~J>_SI+Goz?R%>a2==Z?mHm5o)(letZD+zT-&L?1RdJ6zt@4 zf&#TYZNVC-2^2zZUK}iz-XVAQ0`WSJVX(NK03Zf(LLnrm^|w|$_O$Ax?tj!%Y(Ic(-7oN1(+|f5BQ$EhgrQI?bOr07 zKED_W0?G9FZGTs8a!Yn@JPQ$Uiv?unMl-SHVpOX9IYg_WbSxH1H1caMEQF@eSrXP* zSgg7Ub-{cVCQzE6O3w>mBzOxJ3m+5J=F`ZYgS~T;sbL1N_bQSos|cq;RKN)`!hWz9 ztw6NyRm7XL3LyHa7E{OLx%q(k*zPb&vJys+#nL*a3bLdBHC~Lg0*qJQ0Cyci7qj2?qYTdl;;&< zztCkI7V3iif;Vtl@_sU8S3fVV`kP(jX@oid}rpkl^=$ z;krz?%9bNu_hv=vk_D(i($6Bi@7MZ`FV&`>O+>%bGZKWnzczOfk14TX^Wk6 z9NC`6asts%m>&z#dG6F+!yrD_2jYBwP!ddr)Vx5JJs>{k+oRs%3O4V+Wz=wcbnKkz z0mV5vP@Q)chlFpynuOI<@NQy|2ye;i@1~TPLnL6^+XD9`lVsOlkv+MEgY!F}KChgJ zw1_Nw9*JirON!=bRDFICTO1%sqqExl( zL1#qaB zpwd_Qy-l|o@r7!-x0u}?T3=BwJ-X7Gl~ zE+Nl!5M_2F(57>?@!1lM20?1RHzfJJAuZ@f?K23{0>KcQ=SkG+OFsu=>nt0hRewgV zoUn3X16lqU)*sXab69RTN3GmEg#v$8kB-0vUR?E$Qgj3^n;S2^+H+t*6AmqHf#}R& z$nvF-rHRD81vyZfpH8E1I;8nxAU->otW*inY(5EO0yU~2Xf7;(I-SSmx603tV|jku z`y}TDu+d#fD3MJLSS@}5GvSBO5I#ennMR~rMvc1wYQmW$tiI4(mJZd0Tzo4W@(aRP z)m)kdr9~&9x;Pe!ivw{&{4CsLOIyPYE*9Ua$mQeoRbv&2@yNfDd-ec4Q#~ z(YfxdjVlVpvQUBS+!!|D^=*#gB%4=I7tEQIm>m%$ClJI70sIk*fpBZk!9|yQSRj6O zDE0{!u~ZTz!8Ee+1vK&okSG#i&Iy2uP&zx#k*BIqCX3U`%!{P+a-g%Y90n`OS-J{m zmn7!;lkGYOvn4lRvGg9ah+GdYJI_*Jl!Y>&ESyXYof_c6R3g?;77mahN-$V`8ZyE@ zP+1ZM)umC;SWHyBA{oY;GGVki2FJznZ+fT~T^#5c<89FW2dRb8S5BC0Pq}wwQz5K( z6(RM&3)Fi~pe1Aq^+7|p6gGu(Uejz7=}M=sM6uIIQ0_*Z=M?IEh7qv0mBsWW1l?Kt zG+EKc#E^r5AhEYd)p?0P@t4%5v!NgqNzN&l2KxvoFNlZE@>48pU>6^^aKMd`ujm|4 z0)TXu_sT6IP^EsMFh3sqmy|(8Fat^g1Pp@N`EmjYJW>6lmu)k>L=@&F6sS?-(pqo^ za&r>N;uo=5PZ|C&i1P)q6)IdKQ(KS)**P)va}o;?=q;>d@l)+ZMNE9PmgKMr0JVi_ zEM@D+lKZe;{usK#)ht%ag%0!=*FtaU8K^Euh78#)xdnl27WdHFLZ}g~sxKyzT|ktv zG!Y65=x-46!GX0T=8Hn0yxg1JmDWl8Y-d5xRj&^NUuN+H=y$qgwWDvVyYjh4gCCN+ zjn`$tWm^*>Rqmn6VF;IfKjKRC2Q)>Dp&{TS>ioZ=<$+j37ZJ7+A!?Kp3P20wFFyVl5a0-Q@*rgBO+gS=cheu5H&$KVArcSN`83 z>m;&QApZWog`7afu!R8{3ksmWw2}q(rRS13F3g4e{8*w{YIt-GH<`szuh!yxYIq!x zCPIZoQ(|r)S+N`(THFH1HE*H2s1jNvw%ob%;j63u^vasu`!sft!D$d z%92PDSYH~@1DJp+2~%5NK$N?b+USyW?4IKcjYTA~i&LPoFqYmE!QeuAZusPGJ|An(yUL=us0oMYf+B4_PU0;%V1x53)o)ECowrNd`+>QC*l0MS&C|f=U>z zswF|qhV1-sXp`6)uc?9QifcHr>Mf3~d<0E8CdVJcLJ6FWGFV+mjg!bgAOLd0L<}NX zFyB}Pjpg(jk%r;gd?JVt9NkzAll4W=6-mXxwYgATMg+Yq5(j@shyMCdm~Tye5U6#& zrn%yQ8c&>l+qF4s+$37_RZW=kLnNpUB2lRqQL@hwEB6L@h65qrc#y z-zd&|d_twm2b{5*Mve0ql-m!Z;LrftB0l1j(QBBktA(_%7bN&SVY{IV#!FkEyQByw z)^_8R;d`X(z9Ru{hW7F_Cahxf+;QmpGdQrS0DA?)Aw}e>ydVxTf&l~#evn@n3Q7I| zBGz0ky=zipo?noTNIowFz$^d$VzusS5VzD%V{s-_g;QC|2^TsrTvC7iONm_5ptrmTh9YHbWy}5*r=h+e8*V?mhw~4;Fj#t?&W(YxU#2G!xsSYp%n1aXak3e+VOy^DtOeNewv*`)}@g+hrxJL5=?$dhT+Ee=SglC!iRb$c_RBOuYHd`t*CSwi7K$@&dNFR z90`i=5ib6SNVNx%k}r`c-_JxgOLqXp#|BaBI)LWzF*Jnrk+^FJ`I=GKzDHwIPuk5l1Fyy42fzcWckC%_MgSkbuBo$;xSy;_u}yC z258ec2bPz^YQt5?3x~7DtG_ZIN{hp&hT`a^D#$PPV|1#%A_6MQsBwRv4ZE#%B(gbB zrJt3T2E%mYX&l>93H8;1&{!FbeJdhi@?$QHf6T<8^~um#8w&fqIn8Y)uX(qc`8B3i z4Sbq)HD&B*(b0Dq*$3a?ockDZ4BsI^;T__n-y>S`4I)WYW2Ac!A@vNo2ZvDOGJw{Q zk7y)XZ9VxB&5_e+4E%~3x6i0N{uyOfUs31#85LF^Q13B~O1lX-h}L6|fCEdT;s$)X zjklq*q=?#JB?^wx?78kn$u+ab096`1t}qKBG+_sVX2cU z!g0JMtGx2}De^+m=0vVNN`i?nSXB!Bg9W~@+)~EuKNljq~=w5AAJD-#mUd2v-<`A1|Gs4q?m(pZ{?L#xVhaAg@(7bd`RT@#D9 zaJ^g zn+tGkTQO{QmB4s?9(Ak`=zkvz&D8<#GQ69D``?TU@&xXmQ*Tv$P)RlHKNF_>urW&W z2?C^^!hJ(O&X|8jOV}r5X!Q}LK1YJ=0Fo8@5hM4SYBy5U-l5iMoQQP-*Au>=BkmKf zM1IEQ@Xx6A{DiZ1lPIy7Mxpr>YFtN=r8SH?pHVu08cusIlid%3>e5J9ZM*{KZI5VR zFM#9r>nODyp*l{KS`2wQhYJU2uSg~^h=Kf~U=r3099W&(X1F1P7gyz#e{7Lk93f(` zvbf;z_vO%8LDaam0@{mDLt|+Q4A-7vL4QLU^);4c!+Fy)cbEvfK}{iydIFF1|Z6u-<3j?FU{w z_8(O5cf8%2*$3UWKF}kpf8?jrFyC|rMjK9n+x5sv^dedR zQzWdpFj$|0!y8XQ=lhf3wwXI2R>?%v?5BK$sdv!p39#N?2162N(@nW>5xopI(KhNl z!PvJl5cYd>o3B>A;N5EG?^uW4P0mesX^ODjQ`F@kb{;l6t6;vN0@mbayhUHZW7{jF zDSSb-%QQ}NHwWB1jKsbD2ormXB*g*5%l0Equ^UzPV`%W6MxFlN|-Sx;`}$6GM};UbCbC8TMM zvsGNal8+!eKMZ2?U7))rj%w1R#>%)LUa#hrUsZ7z>oPa_p{hrFX)c_1U4tG`sp^tw z99&%t`;E5{B-#t}bq&329QF{IuFr<;o-@#29|I@xY9^w=N>^Fz)pAQdG}i=?pyt4ET^6ji zR4{Qh`za4cx0K<;&N?FDWE|WON1q@1-by<2>h1PtTX|ym-#A${I`uCXv+o&Oi>2MP z-%|t+$xCn)y?|poO6fZ;fz9Si@DRHX@7*M#Y9nY4`2}Y!2av8jiZ}%>OQ0Ju(yx&y z*N1GaQMS_Ra?l5~M}K4?f%b&YXbR`{6PQBviND~i#YYsGOyHu|M-*E0quiknO+gdz zmT953Qb2=l1~gVA!gljj8t{{8;6IP-gCoc}{04SgFXPz8dX|Nvu`)K%Nv?($SLKyo zXE7AX7tvpxS75mIG#s~e;_wfpFkD+i4Z9saJKy5yh8D76#V}f13EgE}icA%Ze>j8v zt21D=qlC@)ANV02$9Ggwr)-AR_97hGkcI;r5@GTaS^OUpm{3}7D}d?dEVxQufF+5s zt>_t;Z_b0owp(gPexdg#`AHifnd@1ICGe&H1Gq?m<}UFX%I=WLZC!rlflyo-=jmFUA{|Rjo6S$fD8SU|( z(Gu|)&0)Xbf;W-t@vkU3LXSs(#s&AUIDPN~&O3fWD+zXx%1s)m^I`ZyHV%JZi4&V| zLw7|stVvL7oIau0b`b7jH|h1Pwg^SuT~>MJH&Rp=Cy4k?Z(M`3~z)2K$)UrHRN6AX)t&M}xk7;n&T?^w4r=Ynygv2!q zUecFgur3kiTe7f!eH8o^T41&{okTYd2i7N$Ko`POrU3!+?Qj++TH3~mb2n<1&eJ6MLWfDnID2O?X?8blYllXmSQmDF1`|t6uNjm~gZq!)Dj1 zI~MePSZ*#LN^!V@ zoMA+2u_X^4(nOgXGf5b0;iuS4RGI^4i5eKJkH-lyqSPHZ@Y&k{lT8`07cIewJykfV zc7su^?apEx-jqcIb()c}&CYVTN;JV$tOfQv>TrDLdANwS&}TP5XDt`MO@WjA+2)Sw zZY7>*{`+caSeL8G#<=Ilcb>-a-6brx>L$?wf7vb~$2{2Ys)ZwcudZU3ad;gKv^$y* zq1=lIsUcL^lEn|6LZ1EzQkBM#sxXWMxjw{6_aaa411>mC5upy@R_a%DBut|%mfNu9 zD=zwcMfC|1R`bs&F#JRU`vrA=M8GDasQ3PWQ-*J8u)YAJP093~o`S)O3fOMBf+IiH z;H2!k$qfBBLHRn9ybu7d{Pv6f%G{una{ZHjqVM3a?K;fY*TQaV3yy8R058c~FxhYh z2iK*+jI8~!?S&+u`Sd&!hCjwrhpnK;M7T+vN3c>m9nZ#bu_8KthU|ScTqLXEuUwC# zJ9FV7bAdW^Cj8_ZVX`@$Xtj*aD`V+e9JzAD>MM5@{&LsgE!z&;9W_K*<#3UzLzwD4 zmLF^UV+I$R=(dzh>*#qk$O{$x8+Bsr^S@LicN~q>ZmzQ1k$2BxOAZXzXTx2h6;9%f z@Q`eQuk1BAN>tJJl@I$p6*RaJ#cr!W@ZKlz6@QK}i9wXwki`%Dj7*}|Or=RA$n>$A zrZ9#a-4S+k!H%fUxSq_#TR-DU6p?GdN1XHeMB+-sYWf*@2S4Jh`4`kUf5171Pq-EL zugEfd!4{oZkhmMJ%Z0DZ6BeQ}`=KgdN2ErC*CTo5cU7FW4T+qTdtcxw`Vcl-8sRS1 z1(!XYj4+PxK8FMAl8GwoVYR)O1Tq&EM5vAuWw0d?^;Nh8N3m+SOPz!9rbH&9CnV0m zVmk?`LL;1{N@2IB2v$4u>3yf*y_e`$>=aIjmcxlUxWB>`mLuyS(+FqD^K|Syf|Rep zQ??l{;!W_A>x8p-13hnqx6Cyd(BERPE&&I=Pk5W=aXECTcanFjnZMN+w+1)(X_r@- z{gi|gyGm(ryNnQ(M|6#EP;G~oTr)ydZX;6jK927pXR$pW`s?H9JGp{rjb}u)*AS&N zh!nL^T=e{idjAhZt;2{E?M4QPY|7pdB*_mU-(Vb9LZ)#e@eA6MCU7nOE1FM!!X^K| zpvr-)ztt4-4}PNh1;s}`q4?-9%8yN=$>(R}m=2QbDIf=Q7H;D0u-ks6&286hUR;$| ze&?YAA_uKiNj)|{U4fhEb)wg59Q+{*MjLWS46ETof@dR^LjqUd0B}Az=+uX@i4AF|2pzljs)0iRjjg z&h?PKM4wv=f29_Ls9q<5y$%-=bPu^Y7LRolyNCe!E_(lCgztL@XNfxcyHa4aC$H;5 z)-#how5ZtZ?j0A&a&i)lNIBS#VC4sN%{$2z+(CqP7Y$N%aFed5L8^_# z!~+ytV7-&RAE^uQl)i#6h1Up?=|PU(6zY9GW$ zXbzepVx7jVl)sR;{){V;KeO!x&stBT(s~L-#*@f7Fo8-U)-DU<%HUFN)A$18uRa$-lTx$Tbn9(VB$SZ%Gw@ttJRcjhtLwAh&e7ikhr(E^xn z&W7>UIJipHAW-QtJY;L&qi}%;H49d|v*9CON4CBKmOIjkL@%@m;m>+}nsCrRzk-mtnW-9Erv|Bxt`!f^IMT zWFNBZ1e+bD_k1-jo$IbgqX5~PY$DBJPhD5B&zpdezA3)nyQp3)xS{W(T2}8Ue!A0Lt^y~uy6Bp| zAYpxp812`H*!L3Any(O|b{C#<%|x*`i1=?IT>S>z_SO)s()U1O9HMp&o-&u|x?Uz{ z(uEYQ5tjJRS^bKm)5uW%fJB*oB+3pTokTW$-w-bQeMEiW09*3f8a0g$I=3l=6Vkt+ z!fqOQhF_3pFom4`pV1oj7Ze(g;(E-#(rd$Q8RpM8caCgi z6A5btcfTw|s*~`^H<10mKpnM=I&dw#h+N%>YLAQO(uG5AyoM~0#xe}ta1&R=8uSU8%PLlQHO71L>r*eMr2lxP{k)m zJw)`X^B(b9eTY#VMxy2b;&flaTka}}NEb4U`U^V?#`TBaPyg;j_Vw+tb*abN)10Nw zcDT@W3{~lXi{vHt|A(qRK$O-~q#F&;HGhjlonE@0w-KaD!m4(gxr0c}E_f@}(?Hlj z-x=pD&e4EbN!PfUg%aXaxXoCm&>sH@S^GwjC`Z><<{P!9DU2iEU<{p!A8|YFXS794 z;a2+3XpR1gOM$=OywhJ$ZTAJGmYlGTB2#A!7d$6Xe0chPliw#^T$NXN<=-lPa!qnR z@(n#fO3g&8NhGkRVY54rMDRQUl^ftBUWz3BTVy%QsFqOYt-;Y-?nrjT`T0vU#VNINuu6vG}8m?wzUdxY~rBVKK#Z}$BjM3viU zJj0p${*12luehG{Gdk$J%RxV*C4i{a{xfP%d_?Ynzal|-5NFLlOkQ;R z%-af(S9s;$6_1rDGG9l4w8IIbY$XY4H4$hVLNy!Mv1pA>oRBz89k`x^wiw}B z&FmaknG)EEXORfrN4owK1S+(^Pw^t+^@&=Qn~9_@z(ejl32+zL+zxokUm)vRPn67A z+XiM~{S`aO`aVXHEp>MNaikC-rBTf@oj{h!AYyf&QhiRs{0uRA50Gm7xFA^PLREA5 z-QVo3X0Da=YWb>G*83?};iP&yBDFecKx=}xLIWbTJBik>Bh$Eti2fBa=^7**c#Zh| z-N-Q;M4a9W_{d*@A6@H{tE^d6FTCET7y30vhTm5(*7$7jK5_H zLhJtQ7@N(A?q zKKCAy44=SeNA|t5L7iUxJ)^&wUAJx&4{8dBkfyL+ZhINIB4lLc>pJ3iyJn(Vvm2@&Q>?(-p>%sxXEOm2tF%eMU#jXBH0V zNce*53IB?gkpGEhzptpWpGJ}C&u!($K5ygo5?tazv$qCEb|%7nM*^Ir3K2?{G;Cip3FUQ0xBg0Xh}5}CcAlt8 zyOmzMf|P@gNeEsbl%B`x+@WLFkYWB92}Grdy04LAI*hpeFOhv{0I_O)$TAv7n(;g2 zS`3j8KSP?~TN2erM6OQ|O=25O!t5k=mc+cGwKVv?*YjKb8-A^#TAzFWP=e9b!Wga2 znsk#}h^0X$PWuMjaQW;WN5Mk5F`c5NRgeH1NEk|Mv+p z4)+k1J}1F_LD#nf*~YJsV)y|5>gN%uOV{|oJ%p&X(sjH|M0*=~hewcaJc_2UDO_}) z!YS2BCaxJuACR~26G~0Kp!MVw?xg*UdpTTa;1_fz{(^I!Q)u@6OHYZ-&%C%Qukgx$ zXYp66F?WkDq{5BE&{(`mN%@zjcjl$S?SjBgeMtJh!jQ>!JxqyfeF0TF!*VszWtwaGSl zie%$kNH*$X0}^+Q@-2H2yZ;^vtOt;5)r&&AVH#B4Aj_u!3=o)e%fz(6yiC|mc ztyoI~&UM7jEIPx_<;ncnv4abYzh9qg7SGG0AAshzhCi?uW$-iz0%_(TL4EQR8GVqHLoH> zy`HG_D(oe55w3QH#Fd0X>l)GL6Qmt@h#=(#66F>mu)B!gPn2eG4e6$L$O1n=010&N zv8P0(kC0+?AE!xBGmLsrU^Rp?r%@Cf`G8`ZPbjgS###Gexec$q6)@c#54&A?u-lWB1G@KUHCLglh5E+9s;6G=psN&D|2LH`C4xa(qkpM>*1(hfdE zmI+-ygXajR!7Ib;ISKAF`v2c^*%FA-d`QImgs$~{oHBcfaE&(Pm_McW--DC%S-Q?Q zk!*0A1|crwatEmfeROSyQ1AW)o$H7}0vkR}wi@BUtqk z(n%n=i7{WLYD8*Zq0Zh#V)=rJNwUFRqOvNlhktyks%fOw(7$H76RgeuJ~e-;v1NM20C@U$Ym8)@&!yK93;P z^YB%yftOq*0u<_zr1cD0hn^QkX|>g)**C@4r#~^fd9hpO+0DKUAI2vCOeQG`5hUQv6&Is4Mj5r-G4ecDlROlM$-$A4X4LJ58b1a|&g4 zUvSQeNbC47$g>zm_K~;9HYZDL{t}soU*nAJ01`>4i>>;QbnrT|4nJVR606mTOrkh0 zmKmbj1YeaZL};}jN%s-`t}6)LcL{!q=iseS2`{BmBFgg1QTk0~;Rff63q89+tAk#6 zRmVI$(U|tqq9*pS-Gzi_HWw3LST&{gSQPu-52*Be<(FX6mK&|zQI%?V|4bo?VW!y~ zoH_msr!0vkEgm39tq$QTtwi>XNYd{jF{SHZ&`HF3i>}diqW%tqX&zq6+j@LSsFKKj2C9-!YFs5jZN^CwjL>}zM5s5AZS;hQ zwTrASQR|_bD71cwY|DEnuzXEoL&wb?lQ`ZbI(vtV!!J?dIEs=JA5i7+7ZTPlR6ioe zWR$3Fg2ZYNnoy^fP^N=u!E@YD&qAz5v_FfNNzYlFWU(J1|&c_j8ZhHnt4QU@PdI;M67@jAB=soTol@2_%>Y&`ufI_)H)O)Qly zT>T3D-#1yDG>qsrL7$!_)B9|H!IjXTaXfC!DEVuDtZSq*d~&3Kaa}aL1-kTj{f5W~F-f%m9kLmWbfSh*+ng`BMWL&TWxm96-M3 z1Sz;DcyNhA*}z3qhb#)|)P}61o)lJ*|2&cF7V1LxN!{+FPW=(h!9UP@htNfQ#{H{b zP!sf?l-nCLN57_HY$4BQ3Z;RwL@JYL4S9nyuN5Ng4I%L&j~P<0Q>3h)A=P0JNw&{$ z&yEzeWhbs$wjtGd5Q(-u^qmGMRG*NW13%xS(E7G@50T_F?QcX5h3NMjheV-EJDJ@O zV*jN3N}>*9$aEc(Vqd27IO0yWka}JxLVZDD`iP_^QXHNO$uj{nnO-~DPRE^;bV0t$ z0@CPx&bgNQ&7(EqHGQ6euE{D&{7K25e~C8DKHYHMj@l!oZ=}yA z61}jEn)9UE&(5JNa9R{_)mbL!byBl?s8S!IHS8k{X+IOeenExf5sFV9q1yI)eeNIk zPALDu3KaZ;QR+P}ty>u`!!or+WQ!`lRU|t+LayrsDoK$gIrJiv-Y@o^qfq`0DaEfT zf({K4B`L3(&~>z3+(%8wTQr{EqmcM5>I42N>4Ca)2e=>i1@|w1Phsv$v}$%~`)$+( zzmgm-tGzP6S!AmW^gNGpBI+z6xJ*)@?2V9aKTe;wfa}(zQtf&X`{xD;$&-mFZ=LC( zM>mSxSBNB^6Nx?{GA6+oVAY2_)jZvVjA)M7L{0b{ zo%13JJ!eoIxQ3eGHRvMW(Yd`LmHG<0n73%YctB)(2z~qq6bCGzJ?bs)+CC+s9ieOb zO3pjqbDVB2Q>gOi-1Pw|*pKLp{24C_e#AiHk0>~~H(Y6BR`RL}6#SZ?*O*V_IL(+! z{TD^OwuHQ+aGGiYcx~M}m$G)cLJv2q_pelG1#eqDCutZ92naJfON{F!YJPp#pQ0z4) z?M*4RBgpX>CuKPyQ)8TSWd)mTI}ELDAGG$pq;l!|l2T2uc}T=MMEeYhZ$b)fljk{2 z1U`p+w|S&GJx8%8h2Zo#1@wEas}XnY`{?&sB-;!jkq9%_;|1=KYUN^8rs@Tev=M3c zBhcE=b}q|A)MKP(pP|xslL&cC+SeMx*3lTbiX!hBQTMgyRwd-`y0VM5m_2mF(Ye!g zYKt+GQvHOs*gaCPTj;*Lht}{nbi|eE?=e;U zlX);v8Cg}J;8%?ln?ZHD-MEQKj#X=!&jPp|sfNh3J^Ced;U-BJ6nYye?B~`hBay=< z>WCog&%Z-c#1UGekI)%?EWV+gM6#`ndLU0VgA7u!Tv<<7jiSVFiHLAmh_cdeQwm=RXC6t& zU+lU{g!mX*B0Kh2V8YFJofSgN;DVIhfE3HJRgXXKa#u8YVdm8(7T1lf+$NV0h@ zeXQxK5jw_W$={ZGt;@04lYzG@^fb~aaFqHB|$*U?*@LPfU z8|@#8{f*iRzZL0w&2$+;ZP2=ezPhLlDZJ<|yp#f0Y2X}Mqu)S(?ErO=Cdnx_h8>|P zY#;UKj?jDk3z5hNv_%uiM7%_G$R_Q(i@I~KNa1nQ{WIhenPxhTN&zj42#`AllI)+z z2rv616niXFC{CgIsryK_A0%~aK&s;q%Kg?!Wlqq(FC-^gva|lLEFgnHlX3+tKr&klag0epy0QNmhin3jUnrG zP2p>#4Es@eb^-Zb6VMS!Hk{i=y?Td8caunS9gnqUw8tFDAVG5kg})b%(G>E%cnx%1 zqR=?{E$Sn`qtJLCO&4BE(|tXW5G%imvok30m?okk0uNZC*Onwtnqc(=_v{T)mFJM0 z+oL#7SsA!NA^JFy9iAb@W=KA}+;dHeX6cS&@}0C+Po>kM zk*-5a)F#RTh@gFVpn``YUZRA~fzP`&`jBo&`)H4QPsF-UukF!|hR=Tjts(Ew5xs*F zQvXGs({xVDXb9diHHMg!ys82PzXz218!f5=R!mHUMZS|1)|+tu(k_L;q*|liqMFoJ z=f%%xzp@K`ycr!ae?dpoPiT!erqK2idT)Fo;yp$cZCB*Ggs#{lv|f0Raw4GKtNWq= zn}T1VKKMInmn!y{MODB$DNdabCAU{`=*~T^Om3w*>Iqn{1ZOUjBh&%-DroMbbAeAju|Cc|}@2=j?_B&3ll=5#}W+X7NZ zS*O!}_v}YWl`hJDxsJ1>u(`PP0!`uU6JSJ{zY&cT=9l@-)Ad+GXY9T#u~HZI22B@t z>3V&U9BSv4w}*dyk?{O*ad_1#?5#qLNotpy2n2T;D-;ZSaz*%zqB$ z>RA-}Orb)(Bn2AIqu#%IB$G&-chz6|5&D?FqAlt(+B9Z#UOPlR&)A3WNP6JG6)y1X zpf%D&q_jaH{vyhFd^B)@NNrYz9B!O^AYpr!>zJ6zTtBH7<;teuT(rvbn39PoE;ywT z`Q>{}BhPhCUQaqRK*wB_^}*5{264x>k5np8J{hE^H`{576srLl6z*rL#*ldGvGmMl z5n&elEQ+^66{%w;b{#3qMC(3DLGVhcm%nY6ylo~OubR%kniPEfxw&YX0t{kH|f?J3_qa~ckG~#bWq=z!4)f%;rhV!qXi++bf3bD&c zxiy~OAVtd_uOp-|hltRIQRFcvrYLMMQ{*>`yAF?0;l(C41KPi=yQA zDd|a7&7e@4`{`It&yhl;cuVrIqteQi?au90Q!-l1#jYeLQlkz={K>V3@Aw}*-<$3>H*D0jhjY!V)mQ9z8#&Rlvy9e08tH5=MRPMMGpbAI{ zr`irtm~Rvnnqb?DZ0BiGuk%Q8d4dv8Qj%`-k{;mpDs}@a@S3LI4dB6wo3xMgysD;U z{Pwnu9?1?*kx0t6A#@#OzD(u=bc_k;FTFwg#T^v-&p>~TZYUSc=#Dp|>+&bGXx@{u zKQQa#54E)#lac~Zpg_TY50$|inpVv_Q>*3!p4|EweOLd22b!PIL+Y(2=m1R@KBDL9 zPo(bNqATtYr2(r%I`2vKy^*{nw=k7@Eh5u(Sb9qHJV+tBE+9`e2lhZwV$+D2b3G@C zEC*yHHplfJz63<(N!CQ*J}*$_wSilwdJy~PCZyA6CtCI+mB_V#4Y7%!a~zFC-UgHh z&Y>Y>19|S_XpZD@;C0lU+d+M}33U-BI@iylTnQY_kX$8qB2)*g(EHz^#*h77 znZzE+iU@2V%>^o672)O?y(~wQ>oO|~D(1N?kcu@Bnev$I91-9!GTcUpC|^hm)s0h~ za;y@M6>+ZO@mMZ~@%U?!^#Bs>dL&)IT?$OX9QxMKq+?7<5lhx0vwbQA&)x!e zNilP~SatA%OqgZ67*Oav30=e%YJykL5VcL@x`X!Ek7x`(94_@&TB{T&Q1DMcZMgYF zZP17Ldi4=1{Xd{9>Sxr29H2VHgx1K9XrV`S@GDdWZAoFLI%o+c{?kOp8$wP+9F{v7 zP@tml-gQ!PpX_rQZ>g77D4rf;MVo3jOkw$|7`5=~3d!_4o2+mOAxAYO4*#WIt3;xM zQUqf+tyqf&$)ED%R+=M|=71EmxW6^UaY*`Ib6t$c^&Lln#~doWwk3Cao3=?OMa_c* zoNvu>8xz%9;6JovXbovznZ@|&&jYrmd6tjK*4 zU78(Khs~l{y^Fin{kR|ZnjNyt`R< zdlO_k%%Iqloxq;px>c795^$^6bt}De4ctEU5Y52{NK^HrR=rL)f=Lv5O`-V$6ZNpZ zRK0#e`HL%1py2-uecGQ-=%Nqm+AhC`F8Tu+LibR4b{n-suEoC7Vh&U7zb-jUcHLs@ zJ~nRQu7C^*w|Taoi%#MZ;QXAz^)1}A?3Hjo{&WZOT;^nufX%eIbD+eVkFzM&g;yOr%5vLPp8FKi>_(Azx=-A;_;ntCWu;plNXpk|O~!8XJ!X-3rk_-;frz5*2iR#sV6pg_Sd6xG4&>h@@piI+S{aeOT4fozW5)2 z#GS%!&lNFUNhT%AD*)uUOd`j5nh3C8icdEzdt@Y)yj>wou+hI)706cPg&9aTuY8Nu>nS5DAFCd;*dG(w# zr`e5YYgNh+fC2>yekEuOTT`_}Zg%Imj#Ajaj0(SHBF28{HRWOx6WnzQ?^A7grGiBn zL5=uhIpQt!qFmYBrNDFMt39F0fE4>-Sr(i<2zVHPC%rf=Q0coRBwHS^Ecshb4aiCd zr+H1Tr*!;bWVso{RqHNo&t~1V>g{2j`cR{>s8vW+fdU1;PSmQ`PxM@QqfU1k94_}> zm$s+dR=r4fG$74xOnO^W9S3D~fZL}Y%TnLmubSpGfP8OKwXPE~rpjw#C0aj}@SY7< zcx07Hl}BH%pX?U@ST?@SRvGEI2C*&Fp6)||`+^J{q}V(k&UH6x`v6HY%ga|Zzzs+eRs|9MaKTx`lZlikqEY5R%}gn7?6;ktN*;b3zPA!(+?J|S$5`SJ5H+=g{nY-g5Mn~Jhr|m z@tjwcc&%s>tRLj%yUz`$+6@igv3<0Y=`dxEx44hEZ(GE$MQh!MT<2L_`nJ)W?rhje zw0^vkV*ji=%WbqST{WU*)0rz4?cZoE<`ptkpg@5F1qyzP_zyN4`RKUL%sc=9002ov JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 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 管理后台。