mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(keys): 新增 Provider Keys 批量删除 API
- 后端新增 POST /keys/batch-delete 接口,支持一次删除最多 100 个 Key - 按 provider_id 聚合执行副作用,避免逐个删除导致的重复 Redis 操作 - 前端批量操作对话框中删除操作改用批量 API,按 100 个一批分批调用
This commit is contained in:
@@ -83,6 +83,20 @@ export async function deleteEndpointKey(keyId: string): Promise<{ message: strin
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除 Keys
|
||||||
|
*/
|
||||||
|
export interface BatchDeleteKeysResult {
|
||||||
|
success_count: number
|
||||||
|
failed_count: number
|
||||||
|
failed: Array<{ id: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchDeleteEndpointKeys(ids: string[]): Promise<BatchDeleteKeysResult> {
|
||||||
|
const response = await client.post('/api/admin/endpoints/keys/batch-delete', { ids })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========== Provider 级别的 Keys API ==========
|
// ========== Provider 级别的 Keys API ==========
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { listPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
|
import { listPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
|
||||||
import { deleteEndpointKey, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
|
import { batchDeleteEndpointKeys, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
|
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
|
||||||
@@ -617,14 +617,34 @@ async function executeAction(): Promise<void> {
|
|||||||
failedCount += batch.length
|
failedCount += batch.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
|
}
|
||||||
|
} else if (selectedAction.value === 'delete') {
|
||||||
|
// 使用批量删除 API,按 100 个一批分批调用(后端限制 max_length=100)
|
||||||
|
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||||
|
const BATCH_SIZE = 100
|
||||||
|
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||||||
|
|
||||||
|
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||||||
|
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
||||||
|
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
||||||
|
if (totalBatches > 1) {
|
||||||
|
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await batchDeleteEndpointKeys(batch)
|
||||||
|
successCount += result.success_count
|
||||||
|
failedCount += result.failed_count
|
||||||
|
} catch {
|
||||||
|
failedCount += batch.length
|
||||||
|
}
|
||||||
|
|
||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const CONCURRENCY = props.batchConcurrency || 8
|
const CONCURRENCY = props.batchConcurrency || 8
|
||||||
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
||||||
if (selectedAction.value === 'delete') {
|
|
||||||
return () => deleteEndpointKey(key.key_id).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
if (selectedAction.value === 'refresh_oauth') {
|
if (selectedAction.value === 'refresh_oauth') {
|
||||||
if (normalizeText(key.auth_type) !== 'oauth') return null
|
if (normalizeText(key.auth_type) !== 'oauth') return null
|
||||||
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)
|
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ from src.models.endpoint_models import (
|
|||||||
EndpointAPIKeyUpdate,
|
EndpointAPIKeyUpdate,
|
||||||
)
|
)
|
||||||
from src.services.provider_keys import (
|
from src.services.provider_keys import (
|
||||||
|
batch_delete_endpoint_keys_response,
|
||||||
clear_oauth_invalid_response,
|
clear_oauth_invalid_response,
|
||||||
create_provider_key_response,
|
create_provider_key_response,
|
||||||
delete_endpoint_key_response,
|
delete_endpoint_key_response,
|
||||||
@@ -172,6 +173,30 @@ async def delete_endpoint_key(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys/batch-delete")
|
||||||
|
async def batch_delete_endpoint_keys(
|
||||||
|
request: Request,
|
||||||
|
ids: list[str] = Body(..., embed=True, max_length=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
批量删除 Provider Keys
|
||||||
|
|
||||||
|
一次性删除多个 Key,按 Provider 聚合执行副作用(缓存失效、模型关联检查),
|
||||||
|
避免逐个删除导致的重复 Redis 操作和性能问题。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `ids`: Key ID 列表(最多 100 个)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success_count`: 成功删除的数量
|
||||||
|
- `failed_count`: 失败的数量
|
||||||
|
- `failed`: 失败的详情列表
|
||||||
|
"""
|
||||||
|
adapter = AdminBatchDeleteEndpointKeysAdapter(ids=ids)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keys/{key_id}/clear-oauth-invalid")
|
@router.post("/keys/{key_id}/clear-oauth-invalid")
|
||||||
async def clear_oauth_invalid(
|
async def clear_oauth_invalid(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -296,6 +321,16 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
return await delete_endpoint_key_response(db=context.db, key_id=self.key_id)
|
return await delete_endpoint_key_response(db=context.db, key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminBatchDeleteEndpointKeysAdapter(AdminApiAdapter):
|
||||||
|
"""批量删除多个 Provider Key"""
|
||||||
|
|
||||||
|
ids: list[str]
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await batch_delete_endpoint_keys_response(db=context.db, key_ids=self.ids)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminClearOAuthInvalidAdapter(AdminApiAdapter):
|
class AdminClearOAuthInvalidAdapter(AdminApiAdapter):
|
||||||
"""清除 Key 的 OAuth 失效标记。"""
|
"""清除 Key 的 OAuth 失效标记。"""
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"batch_delete_endpoint_keys_response",
|
||||||
"clear_oauth_invalid_response",
|
"clear_oauth_invalid_response",
|
||||||
"create_provider_key_response",
|
"create_provider_key_response",
|
||||||
"delete_endpoint_key_response",
|
"delete_endpoint_key_response",
|
||||||
@@ -46,6 +47,15 @@ async def create_provider_key_response(
|
|||||||
return await _impl(db=db, provider_id=provider_id, key_data=key_data)
|
return await _impl(db=db, provider_id=provider_id, key_data=key_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_delete_endpoint_keys_response(db: Session, key_ids: list[str]) -> dict:
|
||||||
|
"""批量删除 Keys 并返回统一响应(惰性导入实现)。"""
|
||||||
|
from src.services.provider_keys.key_command_service import (
|
||||||
|
batch_delete_endpoint_keys_response as _impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _impl(db=db, key_ids=key_ids)
|
||||||
|
|
||||||
|
|
||||||
async def delete_endpoint_key_response(db: Session, key_id: str) -> dict[str, str]:
|
async def delete_endpoint_key_response(db: Session, key_id: str) -> dict[str, str]:
|
||||||
"""删除 Key 并返回统一响应(惰性导入实现)。"""
|
"""删除 Key 并返回统一响应(惰性导入实现)。"""
|
||||||
from src.services.provider_keys.key_command_service import delete_endpoint_key_response as _impl
|
from src.services.provider_keys.key_command_service import delete_endpoint_key_response as _impl
|
||||||
|
|||||||
@@ -497,3 +497,55 @@ async def delete_endpoint_key_response(db: Session, key_id: str) -> dict[str, st
|
|||||||
)
|
)
|
||||||
logger.warning("[DELETE] 删除 Key: ID={}, Provider={}", key_id, delete_result.provider_id)
|
logger.warning("[DELETE] 删除 Key: ID={}, Provider={}", key_id, delete_result.provider_id)
|
||||||
return {"message": f"Key {key_id} 已删除"}
|
return {"message": f"Key {key_id} 已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_delete_endpoint_keys_response(db: Session, key_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""批量删除 Keys,按 provider_id 聚合后仅执行一次副作用。"""
|
||||||
|
if not key_ids:
|
||||||
|
return {"success_count": 0, "failed_count": 0, "failed": []}
|
||||||
|
|
||||||
|
# 一次查询所有 Key
|
||||||
|
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all()
|
||||||
|
found_ids = {key.id for key in keys}
|
||||||
|
not_found_ids = [kid for kid in key_ids if kid not in found_ids]
|
||||||
|
|
||||||
|
failed: list[dict[str, str]] = [{"id": kid, "error": "not found"} for kid in not_found_ids]
|
||||||
|
|
||||||
|
# 收集受影响的 provider_id
|
||||||
|
affected_provider_ids = {key.provider_id for key in keys if key.provider_id}
|
||||||
|
|
||||||
|
# 批量删除,一次提交
|
||||||
|
success_count = 0
|
||||||
|
try:
|
||||||
|
for key in keys:
|
||||||
|
db.delete(key)
|
||||||
|
db.commit()
|
||||||
|
success_count = len(keys)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("批量删除 Key 提交失败: {}", exc)
|
||||||
|
failed.extend({"id": kid, "error": str(exc)} for kid in found_ids)
|
||||||
|
return {"success_count": 0, "failed_count": len(failed), "failed": failed}
|
||||||
|
|
||||||
|
# 按 provider_id 聚合,每个 provider 仅执行一次副作用
|
||||||
|
for provider_id in affected_provider_ids:
|
||||||
|
try:
|
||||||
|
await run_delete_key_side_effects(
|
||||||
|
db=db,
|
||||||
|
provider_id=provider_id,
|
||||||
|
deleted_key_allowed_models=None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("批量删除副作用执行失败: provider_id={}, Error={}", provider_id, exc)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"[BATCH_DELETE] 批量删除 Keys: success={}, failed={}, providers={}",
|
||||||
|
success_count,
|
||||||
|
len(failed),
|
||||||
|
len(affected_provider_ids),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success_count": success_count,
|
||||||
|
"failed_count": len(failed),
|
||||||
|
"failed": failed,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user