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) <noreply@anthropic.com>
This commit is contained in:
parent
0ac2ef1f27
commit
7feb007f57
36
backend/apps/monitor/migrations/0007_arkapikey.py
Normal file
36
backend/apps/monitor/migrations/0007_arkapikey.py
Normal file
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -48,9 +48,10 @@ urlpatterns = [
|
||||
# Projects
|
||||
path('projects/', views.project_list_view),
|
||||
|
||||
# Ark API Key management
|
||||
path('ark-keys/<str:project_name>/', views.ark_key_list_view),
|
||||
path('ark-keys/<str:project_name>/create/', views.ark_key_create_view),
|
||||
path('ark-keys/<int:key_id>/toggle/', views.ark_key_toggle_view),
|
||||
path('ark-keys/<int:key_id>/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/<int:pk>/', views.ark_key_update_view),
|
||||
path('ark-keys/<int:pk>/delete/', views.ark_key_delete_view),
|
||||
path('ark-keys/<int:pk>/reveal/', views.ark_key_reveal_view),
|
||||
]
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -1,217 +1,239 @@
|
||||
<template>
|
||||
<div style="max-width: 1400px; margin: 0 auto;">
|
||||
<h2 style="margin-bottom: 16px;">API Key 管理</h2>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">API Key 管理</h2>
|
||||
<el-button type="primary" @click="openCreate">录入 API Key</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Project selector -->
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 12px; align-items: center;">
|
||||
<span style="color: #606266;">选择项目:</span>
|
||||
<el-select v-model="selectedProject" placeholder="选择火山项目" filterable
|
||||
style="width: 300px;" @change="loadKeys">
|
||||
<el-option v-for="p in projects" :key="p.name"
|
||||
:label="p.display_name || p.name" :value="p.name" />
|
||||
<!-- Filters -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<el-select v-model="filterUser" placeholder="按子账号筛选" clearable filterable
|
||||
style="width: 200px;" @change="loadKeys">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.display_name || u.username"
|
||||
:value="u.id" />
|
||||
</el-select>
|
||||
<el-button @click="loadProjects" :loading="projectsLoading" text>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showCreateDialog = true"
|
||||
:disabled="!selectedProject">
|
||||
创建 API Key
|
||||
</el-button>
|
||||
<el-select v-model="filterProject" placeholder="按项目筛选" clearable filterable
|
||||
style="width: 200px;" @change="loadKeys">
|
||||
<el-option v-for="p in allProjects" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button @click="loadKeys" text><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<!-- Keys table -->
|
||||
<el-table :data="keys" stripe v-loading="keysLoading" style="width: 100%;"
|
||||
empty-text="请先选择项目">
|
||||
<el-table-column prop="Name" label="名称" min-width="200" />
|
||||
<el-table-column label="API Key" min-width="300">
|
||||
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
|
||||
empty-text="暂无 API Key">
|
||||
<el-table-column label="子账号" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<code style="font-size: 13px; color: #409eff;">{{ row.Key }}</code>
|
||||
{{ row.iam_display_name || row.iam_username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column prop="project_name" label="所属项目" min-width="150" />
|
||||
<el-table-column prop="key_name" label="名称/用途" min-width="160" />
|
||||
<el-table-column label="API Key" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.Status === 'Active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.Status === 'Active' ? '启用' : '停用' }}
|
||||
<code style="color: #999;">{{ row.api_key_hint }}</code>
|
||||
<el-button size="small" text type="primary" @click="handleReveal(row)"
|
||||
style="margin-left: 4px;">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建者" min-width="120">
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="录入时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ getCreator(row) }}
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<el-table-column label="操作" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.CreateTime ? new Date(row.CreateTime).toLocaleString('zh-CN') : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.Status === 'Active'" size="small" text type="warning"
|
||||
@click="handleToggle(row, 'Inactive')">停用</el-button>
|
||||
<el-button v-if="row.status === 'active'" size="small" text type="warning"
|
||||
@click="handleToggle(row, 'disabled')">停用</el-button>
|
||||
<el-button v-else size="small" text type="success"
|
||||
@click="handleToggle(row, 'Active')">启用</el-button>
|
||||
@click="handleToggle(row, 'active')">启用</el-button>
|
||||
<el-button size="small" text type="danger"
|
||||
@click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- Create dialog -->
|
||||
<el-dialog v-model="showCreateDialog" title="创建 API Key" width="90%" style="max-width: 500px;">
|
||||
<!-- Create (manual entry) dialog -->
|
||||
<el-dialog v-model="showCreate" title="录入 API Key" width="90%" style="max-width: 600px;">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px;">
|
||||
请先在火山控制台创建 API Key,然后将完整 Key 粘贴到下方录入。
|
||||
</el-alert>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="所属项目">
|
||||
<el-input :model-value="selectedProject" disabled />
|
||||
<el-form-item label="子账号" required>
|
||||
<el-select v-model="createForm.iam_user_id" placeholder="选择子账号" filterable style="width: 100%;">
|
||||
<el-option v-for="u in users" :key="u.id"
|
||||
:label="`${u.username} (${u.display_name || '-'})`" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Key 名称">
|
||||
<el-input v-model="createName" placeholder="例如:production-key" />
|
||||
<el-form-item label="所属项目" required>
|
||||
<el-select v-model="createForm.project_name" placeholder="选择项目" filterable style="width: 100%;">
|
||||
<el-option v-for="p in volcProjects" :key="p.name"
|
||||
:label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称/用途" required>
|
||||
<el-input v-model="createForm.key_name" placeholder="如:zyc-seedance-production" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input v-model="createForm.api_key" type="textarea" :rows="2"
|
||||
placeholder="粘贴完整的 API Key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createForm.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreate" :loading="creating">创建</el-button>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreate" :loading="creating">录入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Show created key dialog -->
|
||||
<el-dialog v-model="showCreatedKey" title="API Key 创建成功" width="90%" style="max-width: 600px;"
|
||||
:close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px;">
|
||||
<template #title>API Key 仅显示一次,请立即复制保存!</template>
|
||||
</el-alert>
|
||||
<!-- Reveal key dialog -->
|
||||
<el-dialog v-model="showReveal" title="查看 API Key" width="90%" style="max-width: 600px;">
|
||||
<div style="margin-bottom: 8px; color: #606266;">
|
||||
<strong>{{ revealData.key_name }}</strong> · {{ revealData.project_name }}
|
||||
</div>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; word-break: break-all;">
|
||||
<p style="margin-bottom: 8px;"><strong>Key:</strong></p>
|
||||
<code style="font-size: 14px; color: #409eff;">{{ createdKeyValue }}</code>
|
||||
<code style="font-size: 14px; color: #409eff;">{{ revealData.api_key }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="copyCreatedKey">复制 Key</el-button>
|
||||
<el-button @click="showCreatedKey = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyRevealKey">复制</el-button>
|
||||
<el-button @click="showReveal = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
const projects = ref([])
|
||||
const projectsLoading = ref(false)
|
||||
const selectedProject = ref('')
|
||||
const keys = ref([])
|
||||
const keysLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
const users = ref([])
|
||||
const volcProjects = ref([])
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const createName = ref('')
|
||||
const filterUser = ref('')
|
||||
const filterProject = ref('')
|
||||
|
||||
const allProjects = computed(() => {
|
||||
const set = new Set(keys.value.map(k => k.project_name))
|
||||
return [...set].sort()
|
||||
})
|
||||
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({ iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
const showCreatedKey = ref(false)
|
||||
const createdKeyValue = ref('')
|
||||
|
||||
async function loadProjects() {
|
||||
projectsLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
projects.value = data
|
||||
} catch (e) {
|
||||
projects.value = []
|
||||
} finally {
|
||||
projectsLoading.value = false
|
||||
}
|
||||
}
|
||||
const showReveal = ref(false)
|
||||
const revealData = ref({ api_key: '', key_name: '', project_name: '' })
|
||||
|
||||
async function loadKeys() {
|
||||
if (!selectedProject.value) {
|
||||
keys.value = []
|
||||
return
|
||||
}
|
||||
keysLoading.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/ark-keys/${selectedProject.value}/`)
|
||||
keys.value = data.keys || []
|
||||
const params = {}
|
||||
if (filterUser.value) params.iam_user_id = filterUser.value
|
||||
if (filterProject.value) params.project_name = filterProject.value
|
||||
const { data } = await api.get('/api/v1/ark-keys/', { params })
|
||||
keys.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '获取 Key 列表失败')
|
||||
keys.value = []
|
||||
} finally {
|
||||
keysLoading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCreator(row) {
|
||||
const tag = (row.Tags || []).find(t => t.Key === 'sys:ark:createdBy')
|
||||
if (tag) {
|
||||
const parts = tag.Value.split('/')
|
||||
return parts[parts.length - 1] // e.g. "IAMUser/76804896/zyc" → "zyc"
|
||||
}
|
||||
return '-'
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/iam-users/')
|
||||
users.value = data
|
||||
} catch { users.value = [] }
|
||||
}
|
||||
|
||||
async function loadVolcProjects() {
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/projects/')
|
||||
volcProjects.value = data
|
||||
} catch { volcProjects.value = [] }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' }
|
||||
showCreate.value = true
|
||||
loadVolcProjects()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createName.value) {
|
||||
ElMessage.warning('请输入 Key 名称')
|
||||
const f = createForm.value
|
||||
if (!f.iam_user_id || !f.project_name || !f.key_name || !f.api_key) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post(`/api/v1/ark-keys/${selectedProject.value}/create/`, {
|
||||
name: createName.value,
|
||||
})
|
||||
const { data } = await api.post('/api/v1/ark-keys/create/', f)
|
||||
ElMessage.success(data.message)
|
||||
showCreateDialog.value = false
|
||||
createName.value = ''
|
||||
|
||||
// Show the created key (full key is only shown once)
|
||||
const keyValue = data.key?.PrimaryKey || data.key?.Key || ''
|
||||
if (keyValue) {
|
||||
createdKeyValue.value = keyValue
|
||||
showCreatedKey.value = true
|
||||
}
|
||||
|
||||
showCreate.value = false
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '创建失败')
|
||||
ElMessage.error(e.response?.data?.message || '录入失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
async function handleReveal(row) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKeyValue.value)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
const { data } = await api.get(`/api/v1/ark-keys/${row.id}/reveal/`)
|
||||
revealData.value = data
|
||||
showReveal.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRevealKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealData.value.api_key)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row, newStatus) {
|
||||
const action = newStatus === 'Active' ? '启用' : '停用'
|
||||
await ElMessageBox.confirm(`确定${action} "${row.Name}" 吗?`, '确认', { type: 'warning' })
|
||||
const action = newStatus === 'active' ? '启用' : '停用'
|
||||
try {
|
||||
await api.post(`/api/v1/ark-keys/${row.Id}/toggle/`, { status: newStatus })
|
||||
await api.put(`/api/v1/ark-keys/${row.id}/`, { status: newStatus })
|
||||
ElMessage.success(`已${action}`)
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || `${action}失败`)
|
||||
ElMessage.error(`${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除 "${row.Name}" 吗?此操作不可恢复!`, '确认删除', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确定删除',
|
||||
})
|
||||
await ElMessageBox.confirm(`确定删除 "${row.key_name}" 吗?`, '确认删除', { type: 'error' })
|
||||
try {
|
||||
await api.delete(`/api/v1/ark-keys/${row.Id}/delete/`)
|
||||
await api.delete(`/api/v1/ark-keys/${row.id}/delete/`)
|
||||
ElMessage.success('已删除')
|
||||
await loadKeys()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '删除失败')
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProjects()
|
||||
loadKeys()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -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 方式不同,签名方式也不同 |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user