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:
seaislee1209 2026-03-21 01:25:12 +08:00
parent 0ac2ef1f27
commit 7feb007f57
7 changed files with 395 additions and 266 deletions

View 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'],
},
),
]

View File

@ -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')

View File

@ -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()

View File

@ -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),
]

View File

@ -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,
})

View File

@ -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>

View File

@ -35,7 +35,7 @@
| 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess全局无权限 | **完全可行** |
| 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** |
| 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** |
| 子账号能管理自己的 API Key | AirGate 调用方舟 APIPOST + 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-SHA256signed_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 KeyAES-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 明文** | 只返回 IDListApiKeys 返回脱敏值明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 |
| 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 |
| 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
| 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 |