diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 07a72ca..ad3443e 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -47,4 +47,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), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index b202823..a0f07c5 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -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) diff --git a/backend/utils/ark_service.py b/backend/utils/ark_service.py new file mode 100644 index 0000000..2283394 --- /dev/null +++ b/backend/utils/ark_service.py @@ -0,0 +1,42 @@ +"""方舟(Ark)API 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, + }) diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py index fbfdadf..2a6e6ea 100644 --- a/backend/utils/volcengine_client.py +++ b/backend/utils/volcengine_client.py @@ -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") diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 2d7d061..8d2406a 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -12,6 +12,10 @@ 子账号管理 + + + API Key 管理 + 消费监控 @@ -25,7 +29,7 @@ 系统设置 - + 系统管理 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8896347..29c7ff2 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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') }, ], diff --git a/frontend/src/views/ark/ArkKeysView.vue b/frontend/src/views/ark/ArkKeysView.vue new file mode 100644 index 0000000..fa3f94c --- /dev/null +++ b/frontend/src/views/ark/ArkKeysView.vue @@ -0,0 +1,217 @@ + + +