feat(keys): 新增 Provider Keys 批量删除 API

- 后端新增 POST /keys/batch-delete 接口,支持一次删除最多 100 个 Key
- 按 provider_id 聚合执行副作用,避免逐个删除导致的重复 Redis 操作
- 前端批量操作对话框中删除操作改用批量 API,按 100 个一批分批调用
This commit is contained in:
fawney19
2026-03-07 03:49:44 +08:00
parent 7b0908cd87
commit bb268310e2
5 changed files with 136 additions and 5 deletions

View File

@@ -83,6 +83,20 @@ export async function deleteEndpointKey(keyId: string): Promise<{ message: strin
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 ==========

View File

@@ -267,7 +267,7 @@ import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
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 { useProxyNodesStore } from '@/stores/proxy-nodes'
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
}
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)
}
} else {
const CONCURRENCY = props.batchConcurrency || 8
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 (normalizeText(key.auth_type) !== 'oauth') return null
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
from dataclasses import dataclass
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 sqlalchemy.orm import Session
@@ -22,6 +22,7 @@ from src.models.endpoint_models import (
EndpointAPIKeyUpdate,
)
from src.services.provider_keys import (
batch_delete_endpoint_keys_response,
clear_oauth_invalid_response,
create_provider_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)
@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")
async def clear_oauth_invalid(
key_id: str,
@@ -296,6 +321,16 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
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
class AdminClearOAuthInvalidAdapter(AdminApiAdapter):
"""清除 Key 的 OAuth 失效标记。"""

View File

@@ -16,6 +16,7 @@ if TYPE_CHECKING:
)
__all__ = [
"batch_delete_endpoint_keys_response",
"clear_oauth_invalid_response",
"create_provider_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)
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]:
"""删除 Key 并返回统一响应(惰性导入实现)。"""
from src.services.provider_keys.key_command_service import delete_endpoint_key_response as _impl

View File

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