feat: add Ark API Key management (list/create/toggle/delete)

- New VolcengineClient.call_json() for POST+JSON signing (Ark API)
- ArkService for API Key CRUD operations
- Backend views: list/create/toggle/delete ark keys per project
- Frontend: ArkKeysView with project selector, key table, create dialog
- Created key value shown once with copy button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-20 21:36:13 +08:00
parent 8e564ed640
commit 0ac2ef1f27
7 changed files with 406 additions and 13 deletions

View File

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

View File

@ -12,6 +12,7 @@ from rest_framework.response import Response
from utils.crypto import encrypt, decrypt, make_hint
from utils.iam_service import IAMService, ProjectService
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
@ -921,3 +922,104 @@ def project_list_view(request):
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
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
@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)
@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)
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)
@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)
try:
svc.update_api_key_status(key_id, new_status)
action = '启用' if new_status == 'Active' else '停用'
AlertRecord.objects.create(
alert_type=AlertRecord.AlertType.MANUAL,
title=f"{action}方舟 API Key (ID: {key_id})",
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)
@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)
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)

View File

@ -0,0 +1,42 @@
"""方舟ArkAPI Key 管理服务"""
import logging
from .volcengine_client import VolcengineClient, VolcengineAPIError, get_ark_client
logger = logging.getLogger(__name__)
class ArkService:
"""方舟 API Key 管理"""
def __init__(self, ak: str, sk: str):
self.client = get_ark_client(ak, sk)
def list_api_keys(self, project_name: str, page_size: int = 100, page_number: int = 1) -> dict:
"""列出项目下的 API Key"""
return self.client.call_json("ListApiKeys", {
"ProjectName": project_name,
"PageSize": page_size,
"PageNumber": page_number,
})
def create_api_key(self, project_name: str, name: str, resource_type: str = "all") -> dict:
"""在项目下创建 API Key"""
return self.client.call_json("CreateApiKey", {
"ProjectName": project_name,
"Name": name,
"ResourceInstances": [{"ResourceId": "*", "ResourceType": resource_type}],
})
def delete_api_key(self, api_key_id: int) -> dict:
"""删除 API Key"""
return self.client.call_json("DeleteApiKey", {
"Id": api_key_id,
})
def update_api_key_status(self, api_key_id: int, status: str) -> dict:
"""启用/停用 API Key (status: Active / Inactive)"""
return self.client.call_json("UpdateApiKey", {
"Id": api_key_id,
"Status": status,
})

View File

@ -46,25 +46,25 @@ class VolcengineClient:
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 = "", extra_headers: dict = None) -> dict:
params = params or {}
def _sign_and_call(self, action: str, method: str, content_type: str,
query_params: dict, body_bytes: bytes) -> dict:
"""统一签名并调用"""
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}
x_content_sha256 = hashlib.sha256(body_bytes).hexdigest()
query_string = self._norm_query(query_params)
signed_headers_str = "content-type;host;x-content-sha256;x-date"
canonical_headers = (
f"content-type:application/x-www-form-urlencoded\n"
f"content-type:{content_type}\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,
method, "/", query_string,
canonical_headers, "", signed_headers_str, x_content_sha256
])
@ -84,19 +84,19 @@ class VolcengineClient:
"Host": self.host,
"X-Date": x_date,
"X-Content-Sha256": x_content_sha256,
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": content_type,
"Authorization": (
f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, "
f"SignedHeaders={signed_headers_str}, Signature={signature}"
),
}
if extra_headers:
headers.update(extra_headers)
url = f"https://{self.host}/?{query_string}"
try:
r = requests.get(url, headers=headers, timeout=30)
if method == "GET":
r = requests.get(url, headers=headers, timeout=30)
else:
r = requests.post(url, headers=headers, data=body_bytes, timeout=30)
resp = r.json()
except Exception as e:
raise VolcengineAPIError(action, "NetworkError", str(e))
@ -108,6 +108,21 @@ class VolcengineClient:
)
return resp
def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict:
"""GET 方式调用IAM / Billing 等传统接口)"""
params = params or {}
all_params = {"Action": action, "Version": self.version, **params}
return self._sign_and_call(action, "GET", "application/x-www-form-urlencoded",
all_params, body.encode("utf-8") if body else b"")
def call_json(self, action: str, body: dict = None) -> dict:
"""POST + JSON body 方式调用(方舟 Ark 等新接口)"""
import json
query_params = {"Action": action, "Version": self.version}
body_bytes = json.dumps(body or {}).encode("utf-8")
return self._sign_and_call(action, "POST", "application/json",
query_params, body_bytes)
def get_iam_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com")
@ -121,3 +136,9 @@ def get_billing_client(ak: str, sk: str) -> VolcengineClient:
def get_resource_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
version="2021-08-01")
def get_ark_client(ak: str, sk: str) -> VolcengineClient:
"""方舟 API 客户端(使用 POST + JSON body"""
return VolcengineClient(ak, sk, "ark", "open.volcengineapi.com",
region="cn-beijing", version="2024-01-01")

View File

@ -12,6 +12,10 @@
<el-icon><User /></el-icon>
<span>子账号管理</span>
</el-menu-item>
<el-menu-item index="/ark-keys">
<el-icon><Key /></el-icon>
<span>API Key 管理</span>
</el-menu-item>
<el-menu-item index="/billing">
<el-icon><Wallet /></el-icon>
<span>消费监控</span>
@ -25,7 +29,7 @@
<span>系统设置</span>
</el-menu-item>
<el-menu-item index="/admin">
<el-icon><Key /></el-icon>
<el-icon><Tools /></el-icon>
<span>系统管理</span>
</el-menu-item>
</el-menu>

View File

@ -16,6 +16,7 @@ const routes = [
{ 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: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') },
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') },
],

View File

@ -0,0 +1,217 @@
<template>
<div style="max-width: 1400px; margin: 0 auto;">
<h2 style="margin-bottom: 16px;">API Key 管理</h2>
<!-- 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" />
</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>
</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">
<template #default="{ row }">
<code style="font-size: 13px; color: #409eff;">{{ row.Key }}</code>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<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">
<template #default="{ row }">
{{ getCreator(row) }}
</template>
</el-table-column>
<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-else size="small" text type="success"
@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;">
<el-form label-width="100px">
<el-form-item label="所属项目">
<el-input :model-value="selectedProject" disabled />
</el-form-item>
<el-form-item label="Key 名称">
<el-input v-model="createName" placeholder="例如production-key" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = 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>
<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>
</div>
<template #footer>
<el-button type="primary" @click="copyCreatedKey">复制 Key</el-button>
<el-button @click="showCreatedKey = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, 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 showCreateDialog = ref(false)
const createName = ref('')
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
}
}
async function loadKeys() {
if (!selectedProject.value) {
keys.value = []
return
}
keysLoading.value = true
try {
const { data } = await api.get(`/api/v1/ark-keys/${selectedProject.value}/`)
keys.value = data.keys || []
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取 Key 列表失败')
keys.value = []
} finally {
keysLoading.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 handleCreate() {
if (!createName.value) {
ElMessage.warning('请输入 Key 名称')
return
}
creating.value = true
try {
const { data } = await api.post(`/api/v1/ark-keys/${selectedProject.value}/create/`, {
name: createName.value,
})
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
}
await loadKeys()
} catch (e) {
ElMessage.error(e.response?.data?.message || '创建失败')
} finally {
creating.value = false
}
}
async function copyCreatedKey() {
try {
await navigator.clipboard.writeText(createdKeyValue.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
async function handleToggle(row, newStatus) {
const action = newStatus === 'Active' ? '启用' : '停用'
await ElMessageBox.confirm(`确定${action} "${row.Name}" 吗?`, '确认', { type: 'warning' })
try {
await api.post(`/api/v1/ark-keys/${row.Id}/toggle/`, { status: newStatus })
ElMessage.success(`${action}`)
await loadKeys()
} catch (e) {
ElMessage.error(e.response?.data?.message || `${action}失败`)
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除 "${row.Name}" 吗?此操作不可恢复!`, '确认删除', {
type: 'error',
confirmButtonText: '确定删除',
})
try {
await api.delete(`/api/v1/ark-keys/${row.Id}/delete/`)
ElMessage.success('已删除')
await loadKeys()
} catch (e) {
ElMessage.error(e.response?.data?.message || '删除失败')
}
}
onMounted(() => {
loadProjects()
})
</script>