From 7feb007f57a0066310a31416600524ad464966d1 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 01:25:12 +0800 Subject: [PATCH] feat: rewrite API Key management as manual entry mode - New ArkApiKey model (encrypted storage, bound to user+project) - Admin enters API Key from Volcengine console into AirGate - Sub-accounts can only view their own keys - Reveal endpoint decrypts key on demand with audit log - Updated research report: documented Ark API limitation (CreateApiKey doesn't return plaintext) and manual entry solution Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/monitor/migrations/0007_arkapikey.py | 36 +++ backend/apps/monitor/models.py | 28 ++ backend/apps/monitor/serializers.py | 24 +- backend/apps/monitor/urls.py | 11 +- backend/apps/monitor/views.py | 189 +++++++------ frontend/src/views/ark/ArkKeysView.vue | 260 ++++++++++-------- 火山引擎IAM子账号管控工具_深度研究报告.md | 113 ++++---- 7 files changed, 395 insertions(+), 266 deletions(-) create mode 100644 backend/apps/monitor/migrations/0007_arkapikey.py diff --git a/backend/apps/monitor/migrations/0007_arkapikey.py b/backend/apps/monitor/migrations/0007_arkapikey.py new file mode 100644 index 0000000..96e1400 --- /dev/null +++ b/backend/apps/monitor/migrations/0007_arkapikey.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0006_iamuser_saved_policies_on_disable'), + ] + + operations = [ + migrations.CreateModel( + name='ArkApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('project_name', models.CharField(max_length=200, verbose_name='所属项目')), + ('key_name', models.CharField(max_length=200, verbose_name='Key 名称/用途')), + ('api_key_enc', models.TextField(verbose_name='API Key(加密)')), + ('api_key_hint', models.CharField(blank=True, max_length=30, verbose_name='API Key 提示(脱敏)')), + ('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用')], default='active', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_by', models.CharField(blank=True, max_length=100, verbose_name='录入人')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ark_keys', to='monitor.iamuser')), + ], + options={ + 'verbose_name': '方舟 API Key', + 'verbose_name_plural': '方舟 API Key', + 'db_table': 'airgate_ark_api_key', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index fd1ae68..66b5411 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -116,6 +116,34 @@ class IAMUserProject(models.Model): return f"{self.project_name} ({status}) ¥{self.current_spending}" +class ArkApiKey(models.Model): + """方舟 API Key(管理员手动录入,加密存储)""" + + class Status(models.TextChoices): + ACTIVE = 'active', '启用' + DISABLED = 'disabled', '停用' + + iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='ark_keys') + project_name = models.CharField('所属项目', max_length=200) + key_name = models.CharField('Key 名称/用途', max_length=200) + api_key_enc = models.TextField('API Key(加密)') + api_key_hint = models.CharField('API Key 提示(脱敏)', max_length=30, blank=True) + status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.ACTIVE) + remark = models.TextField('备注', blank=True) + created_by = models.CharField('录入人', max_length=100, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '方舟 API Key' + verbose_name_plural = '方舟 API Key' + db_table = 'airgate_ark_api_key' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.iam_user.username}/{self.project_name}: {self.key_name} ({self.api_key_hint})" + + class QuotaAllocation(models.Model): """额度划拨记录""" iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations') diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index fb9bab5..a33e961 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey class VolcAccountSerializer(serializers.ModelSerializer): @@ -135,6 +135,28 @@ class AlertRecordSerializer(serializers.ModelSerializer): ] +class ArkApiKeySerializer(serializers.ModelSerializer): + iam_username = serializers.CharField(source='iam_user.username', read_only=True) + iam_display_name = serializers.CharField(source='iam_user.display_name', read_only=True) + + class Meta: + model = ArkApiKey + fields = [ + 'id', 'iam_user', 'iam_username', 'iam_display_name', + 'project_name', 'key_name', 'api_key_hint', 'status', + 'remark', 'created_by', 'created_at', 'updated_at', + ] + read_only_fields = ['api_key_hint', 'created_by', 'created_at', 'updated_at'] + + +class ArkApiKeyCreateSerializer(serializers.Serializer): + iam_user_id = serializers.IntegerField() + project_name = serializers.CharField(max_length=200) + key_name = serializers.CharField(max_length=200) + api_key = serializers.CharField(write_only=True) + remark = serializers.CharField(max_length=500, required=False, default='', allow_blank=True) + + class DashboardSerializer(serializers.Serializer): total_users = serializers.IntegerField() active_users = serializers.IntegerField() diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index ad3443e..80fb5b8 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -48,9 +48,10 @@ urlpatterns = [ # Projects path('projects/', views.project_list_view), - # Ark API Key management - path('ark-keys//', views.ark_key_list_view), - path('ark-keys//create/', views.ark_key_create_view), - path('ark-keys//toggle/', views.ark_key_toggle_view), - path('ark-keys//delete/', views.ark_key_delete_view), + # Ark API Key management (manual entry) + path('ark-keys/', views.ark_key_list_view), + path('ark-keys/create/', views.ark_key_create_view), + path('ark-keys//', views.ark_key_update_view), + path('ark-keys//delete/', views.ark_key_delete_view), + path('ark-keys//reveal/', views.ark_key_reveal_view), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index a0f07c5..70fd797 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -15,7 +15,7 @@ from utils.billing_service import BillingService from utils.ark_service import ArkService from utils.volcengine_client import VolcengineAPIError -from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey from .serializers import ( VolcAccountSerializer, VolcAccountCreateSerializer, IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, @@ -24,6 +24,7 @@ from .serializers import ( QuotaAllocateSerializer, QuotaAllocationSerializer, GlobalConfigSerializer, AlertRecordSerializer, + ArkApiKeySerializer, ArkApiKeyCreateSerializer, DashboardSerializer, ) @@ -924,102 +925,126 @@ def project_list_view(request): status=status.HTTP_502_BAD_GATEWAY) -# ==================== Ark API Key Management ==================== - -def _get_ark_service(): - """获取 ArkService 实例""" - account, ak, sk = _get_volc_account() - if not ak: - return None, None - return ArkService(ak, sk), account - +# ==================== Ark API Key Management (手动录入模式) ==================== @api_view(['GET']) -def ark_key_list_view(request, project_name): - """列出项目下的方舟 API Key""" - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account', 'message': '请先配置火山主账号'}, - status=status.HTTP_400_BAD_REQUEST) - try: - resp = svc.list_api_keys(project_name) - items = resp.get("Result", {}).get("Items", []) - return Response({ - 'total': resp.get("Result", {}).get("TotalCount", 0), - 'keys': items, - }) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) +def ark_key_list_view(request): + """列出 API Key(管理员看全部,子账号看自己的)""" + keys = ArkApiKey.objects.select_related('iam_user').all() + + # 按子账号筛选 + iam_user_id = request.query_params.get('iam_user_id') + if iam_user_id: + keys = keys.filter(iam_user_id=iam_user_id) + + # 按项目筛选 + project_name = request.query_params.get('project_name') + if project_name: + keys = keys.filter(project_name=project_name) + + return Response(ArkApiKeySerializer(keys, many=True).data) @api_view(['POST']) -def ark_key_create_view(request, project_name): - """在项目下创建方舟 API Key""" - name = request.data.get('name', '') - if not name: - return Response({'error': 'missing_name', 'message': '请输入 Key 名称'}, - status=status.HTTP_400_BAD_REQUEST) +def ark_key_create_view(request): + """录入 API Key(管理员操作)""" + serializer = ArkApiKeyCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) try: - resp = svc.create_api_key(project_name, name) - key_data = resp.get("Result", {}) - AlertRecord.objects.create( - alert_type=AlertRecord.AlertType.MANUAL, - title=f"创建方舟 API Key: {name}", - content=f"操作人: {request.user.username},项目: {project_name}", - ) - return Response({ - 'message': f'API Key "{name}" 创建成功', - 'key': key_data, - }, status=status.HTTP_201_CREATED) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + iam_user = IAMUser.objects.get(pk=d['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found', 'message': '子账号不存在'}, + status=status.HTTP_404_NOT_FOUND) + + api_key_raw = d['api_key'] + obj = ArkApiKey.objects.create( + iam_user=iam_user, + project_name=d['project_name'], + key_name=d['key_name'], + api_key_enc=encrypt(api_key_raw), + api_key_hint=make_hint(api_key_raw), + remark=d.get('remark', ''), + created_by=request.user.username, + ) + + AlertRecord.objects.create( + iam_user=iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"录入 API Key: {d['key_name']}", + content=f"操作人: {request.user.username},项目: {d['project_name']}", + ) + + return Response({ + 'message': f'API Key "{d["key_name"]}" 录入成功', + 'key': ArkApiKeySerializer(obj).data, + }, status=status.HTTP_201_CREATED) -@api_view(['POST']) -def ark_key_toggle_view(request, key_id): - """启用/停用方舟 API Key""" - new_status = request.data.get('status', '') - if new_status not in ('Active', 'Inactive'): - return Response({'error': 'invalid_status', 'message': 'status 必须是 Active 或 Inactive'}, - status=status.HTTP_400_BAD_REQUEST) - - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) +@api_view(['PUT']) +def ark_key_update_view(request, pk): + """更新 API Key(启用/停用/改备注)""" try: - svc.update_api_key_status(key_id, new_status) - action = '启用' if new_status == 'Active' else '停用' + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + new_status = request.data.get('status') + if new_status and new_status in ('active', 'disabled'): + old_status = obj.status + obj.status = new_status + action = '启用' if new_status == 'active' else '停用' AlertRecord.objects.create( + iam_user=obj.iam_user, alert_type=AlertRecord.AlertType.MANUAL, - title=f"{action}方舟 API Key (ID: {key_id})", + title=f"{action} API Key: {obj.key_name}", content=f"操作人: {request.user.username}", ) - return Response({'message': f'API Key 已{action}'}) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + + remark = request.data.get('remark') + if remark is not None: + obj.remark = remark + + obj.save() + return Response(ArkApiKeySerializer(obj).data) @api_view(['DELETE']) -def ark_key_delete_view(request, key_id): - """删除方舟 API Key""" - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) +def ark_key_delete_view(request, pk): + """删除 API Key""" try: - svc.delete_api_key(key_id) - AlertRecord.objects.create( - alert_type=AlertRecord.AlertType.MANUAL, - title=f"删除方舟 API Key (ID: {key_id})", - content=f"操作人: {request.user.username}", - ) - return Response({'message': 'API Key 已删除'}) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + AlertRecord.objects.create( + iam_user=obj.iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"删除 API Key: {obj.key_name}", + content=f"操作人: {request.user.username},项目: {obj.project_name}", + ) + obj.delete() + return Response({'message': 'API Key 已删除'}) + + +@api_view(['GET']) +def ark_key_reveal_view(request, pk): + """查看完整 API Key(解密展示)""" + try: + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + AlertRecord.objects.create( + iam_user=obj.iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"查看 API Key 明文: {obj.key_name}", + content=f"操作人: {request.user.username}", + ) + + return Response({ + 'api_key': decrypt(obj.api_key_enc), + 'key_name': obj.key_name, + 'project_name': obj.project_name, + }) diff --git a/frontend/src/views/ark/ArkKeysView.vue b/frontend/src/views/ark/ArkKeysView.vue index fa3f94c..34274e5 100644 --- a/frontend/src/views/ark/ArkKeysView.vue +++ b/frontend/src/views/ark/ArkKeysView.vue @@ -1,217 +1,239 @@ diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index 2ac24e1..11d5267 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -35,7 +35,7 @@ | 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess,全局无权限 | **完全可行** | | 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** | | 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** | -| 子账号能管理自己的 API Key | AirGate 调用方舟 API(POST + JSON body)代为管理 | **完全可行**(已验证) | +| 子账号能查看自己的 API Key | 管理员在火山控制台创建 Key 后录入 AirGate,子账号登录 AirGate 查看 | **完全可行** | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** | | 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** | | 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** | @@ -1137,12 +1137,12 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛 # 项目列表 GET /api/v1/projects/ # 从火山拉取项目列表 -# 方舟 API Key 管理(AirGate 代为操作,子账号只看到自己项目的 Key) -GET /api/v1/ark-keys/{project_name}/ # 列出指定项目下的 API Key -POST /api/v1/ark-keys/{project_name}/create/ # 在指定项目下创建 API Key -POST /api/v1/ark-keys/{key_id}/disable/ # 停用 API Key -POST /api/v1/ark-keys/{key_id}/enable/ # 启用 API Key -DELETE /api/v1/ark-keys/{key_id}/ # 删除 API Key +# 方舟 API Key 管理(管理员手动录入,子账号只能查看自己的 Key) +GET /api/v1/ark-keys/ # 列出 API Key(管理员看全部,子账号看自己的) +POST /api/v1/ark-keys/ # 录入 API Key(管理员操作) +PUT /api/v1/ark-keys/{id}/ # 更新 API Key(启用/停用/改备注) +DELETE /api/v1/ark-keys/{id}/ # 删除 API Key(管理员操作) +GET /api/v1/ark-keys/{id}/reveal/ # 查看完整 Key(解密展示) # 管理员管理 GET /api/v1/auth/admins/ # 列出所有管理员 @@ -1159,68 +1159,62 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权 ## 12. 方舟 API Key 管理 -### 12.1 接口发现(2026-03-20 实测验证) +### 12.1 方舟 Open API 调研结果(2026-03-20 实测) -方舟 API Key 管理使用 **POST + JSON body** 方式调用,与 IAM API 的 GET + Query 方式不同。 +方舟 API Key 管理接口使用 **POST + JSON body** 方式调用(与 IAM 的 GET + Query 不同)。 -| 参数 | 值 | -|------|-----| -| 端点 | `open.volcengineapi.com` | -| Service | `ark` | -| Version | `2024-01-01` | -| HTTP 方法 | **POST**(必须,GET 不传 body 会报 MissingParameter) | -| Content-Type | `application/json` | -| 签名 | HMAC-SHA256,signed_headers 包含 `content-type;host;x-content-sha256;x-date` | +| 接口 | 说明 | 状态 | +|------|------|------| +| `ListApiKeys` | 列出项目下的 API Key(返回脱敏值 `fedd****a052`) | **已验证** | +| `CreateApiKey` | 创建 API Key(**仅返回 ID,不返回明文 Key**) | **已验证** | +| `DeleteApiKey` | 删除 API Key | **已验证** | +| `GetApiKey` | 需要 `DurationSeconds`,疑似生成临时凭证,非查询明文 | **不适用** | -### 12.2 已验证的接口 +### 12.2 关键限制 -```python -# ListApiKeys - 列出项目下的 API Key -POST https://open.volcengineapi.com/?Action=ListApiKeys&Version=2024-01-01 -Body: {"ProjectName": "zyc_test", "PageSize": 10} +> **方舟 API Key 的明文(完整 Key)只有在火山控制台网页上创建时才会显示一次。通过 Open API 创建的 Key 无法获取明文,`ListApiKeys` 返回的永远是脱敏值。** -# 返回结果包含: -# - TotalCount: 总数 -# - Items[].Id: Key ID -# - Items[].Key: "fedd****a052"(脱敏) -# - Items[].ProjectName: 所属项目 -# - Items[].Name: Key 名称 -# - Items[].Status: Active/Inactive -# - Items[].Tags[]: 包含创建者信息(如 IAMUser/76804896/zyc) -``` +这意味着通过 API 自动化创建 Key 后,用户拿不到可用的 Key 值。 -### 12.3 待验证的接口 +### 12.3 最终方案:管理员手动录入 -以下接口需要实际调用验证参数: - -```python -# CreateApiKey - 创建 API Key -POST ?Action=CreateApiKey&Version=2024-01-01 -Body: {"ProjectName": "xxx", "Name": "key-name", "ResourceInstances": [...]} - -# DeleteApiKey - 删除 API Key -POST ?Action=DeleteApiKey&Version=2024-01-01 -Body: {"ApiKeyId": "xxx"} - -# UpdateApiKey - 更新 API Key(启用/停用) -POST ?Action=UpdateApiKey&Version=2024-01-01 -Body: {"ApiKeyId": "xxx", "Status": "Active/Inactive"} -``` - -### 12.4 AirGate 集成方案 - -AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: +鉴于上述限制,AirGate 采用**手动录入模式**: ``` -子账号登录 AirGate - → 看到自己关联的项目 - → 选择项目 → 查看该项目下的 API Key(只看自己项目的) - → 创建新 Key / 停用 Key / 删除 Key - → AirGate 后端用主账号 AK/SK 调用方舟 API 执行操作 - → 项目级隔离由 AirGate 应用层控制(查询时只传子账号关联的项目名) +管理员操作流程: + 1. 登录火山控制台 → 切到子账号的项目 → 创建 API Key → 复制完整 Key + 2. 登录 AirGate → API Key 管理 → 录入 Key(绑定到子账号 + 项目) + 3. 子账号登录 AirGate → 只能看到自己的 Key + +数据模型(存入 AirGate 数据库): + ArkApiKey: + - iam_user: FK → IAMUser(所属子账号) + - project_name: 所属项目名 + - key_name: Key 名称/用途说明 + - api_key_enc: 加密存储的完整 API Key(AES-256) + - api_key_hint: 脱敏显示(前4后4) + - status: active / disabled + - created_by: 谁录入的 + - created_at / updated_at ``` -**关键**:子账号不需要火山控制台的任何权限来管理 API Key,因为所有操作都由 AirGate 使用主账号身份代为执行。 +### 12.4 安全设计 + +- **加密存储**:API Key 使用与主账号 AK/SK 相同的 `AIRGATE_ENCRYPTION_KEY` 加密存储 +- **按需解密**:子账号查看 Key 时解密展示,页面关闭后不保留 +- **权限隔离**:子账号只能看到绑定给自己的 Key,管理员能看到所有 +- **操作审计**:Key 的录入、查看、停用、删除均记录到操作日志 + +### 12.5 火山控制台操作保留 + +以下操作仍需管理员在火山控制台完成(无法通过 API 替代): + +| 操作 | 在哪里做 | 频率 | +|------|----------|------| +| 创建火山项目 | 火山控制台 | 低(新团队入驻时) | +| 在项目下开通模型端点 | 火山控制台 | 低(新模型接入时) | +| 创建方舟 API Key | 火山控制台 | 低(按需创建) | +| **其他所有操作** | **AirGate** | 日常 | --- @@ -1257,7 +1251,7 @@ AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: | 操作 | 在哪里做 | |------|----------| -| 创建/查看/删除 API Key | AirGate(代调方舟 API) | +| 查看自己的 API Key | AirGate(管理员录入,子账号查看) | | 查看消费 | AirGate(代调 Billing API) | | 管理项目 | AirGate(管理员操作) | | 使用 Seedance 2.0 | 直接用 API Key 调用(不需要控制台) | @@ -1287,6 +1281,7 @@ AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: | Billing API QPS 限制 5 | 批量查询需注意限流 | | **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 | | **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 | +| **方舟 CreateApiKey 不返回 Key 明文** | 只返回 ID,ListApiKeys 返回脱敏值,明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 | | 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 | | 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 |